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).


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
================================================
16dp72dp4dp12dp4dp72dp4dp2dp-2dp72dp42dp8dp42dp52dp32dp8dp32dp16dp32sp18sp
================================================
FILE: app/src/main/res/values/strings.xml
================================================
UAMPSkip back 10sSkip forward 10sPlayPauseQueueAlbum art--:--%d:%02dFailed 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
================================================
================================================
FILE: automotive/src/main/res/layout/activity_settings.xml
================================================
================================================
FILE: automotive/src/main/res/layout/activity_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout/phone_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout/pin_item.xml
================================================
================================================
FILE: automotive/src/main/res/layout/pin_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout/preference.xml
================================================
================================================
FILE: automotive/src/main/res/layout/preference_category.xml
================================================
================================================
FILE: automotive/src/main/res/layout/qr_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout/sign_in_landing_page.xml
================================================
================================================
FILE: automotive/src/main/res/layout/sign_in_landing_page_with_username_and_password.xml
================================================
================================================
FILE: automotive/src/main/res/layout/username_and_password_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout-h900dp/phone_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout-h900dp/pin_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout-h900dp/qr_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/layout-h900dp/sign_in_landing_page.xml
================================================
================================================
FILE: automotive/src/main/res/layout-h900dp/sign_in_landing_page_with_username_and_password.xml
================================================
================================================
FILE: automotive/src/main/res/layout-h900dp/username_and_password_sign_in.xml
================================================
================================================
FILE: automotive/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: automotive/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
================================================
FILE: automotive/src/main/res/values/colors.xml
================================================
#840255#710144#14A5A1#202020#fff1f3f4#ffdadce0#ffffffff@color/grey_300
================================================
FILE: automotive/src/main/res/values/dimens.xml
================================================
16dp16dp64sp32sp26sp158dp56dp56dp24dp48dp96dp4dp2dp28dp48dp96dp32dp24dp642dp420dp48dp48dp32dp64dp48dp16dp8dp4dp96dp32dp0.10.9
================================================
FILE: automotive/src/main/res/values/strings.xml
================================================
UAMPUAMP SettingsLoginAuthentication requiredEnter passwordEmailLoginPlease fill in missing fieldsForgot username? | Use phone app to sign inForgot password? | Reset with phone appNextNextEnter usernameInvalid inputSign in with AuralSign in with AuralSign in with phoneChoose phone sign-in methodPINSign in with PINGo to example.com/fast-pair on your phoneQR codeSign in with QR codeUse your phone\'s camera to capture this codeSign in with GoogleBy signing in with Aural, you agree to the <a href="http://www.google.com">Terms & Conditions</a> and <a href="http://www.google.com">Privacy policy</a>https://chart.apis.google.com/chart?cht=qr&chs=392x392&chl=https://www.youtube.com/watch?v=8I8mCFYYfdkSign in successfulSign in failed with error code: %1$dYOUR_SERVER_CLIENT_ID
================================================
FILE: automotive/src/main/res/values/styles.xml
================================================
================================================
FILE: automotive/src/main/res/values-h1060dp/dimens.xml
================================================
64dp116dp64dp
================================================
FILE: automotive/src/main/res/xml/automotive_app_desc.xml
================================================
================================================
FILE: automotive/src/main/res/xml/preferences.xml
================================================
================================================
FILE: automotive/src/test/java/com/example/android/uamp/automotive/ExampleUnitTest.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 org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see Testing documentation
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}
================================================
FILE: 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.
*/
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
// App SDK versions.
compileSdkVersion = 30
minSdkVersion = 19
targetSdkVersion = 30
// Dependency versions.
androidx_app_compat_version = '1.2.0'
androidx_car_version = '1.0.0-alpha7'
androidx_core_ktx_version = '1.3.1'
androidx_media_version = '1.0.1'
androidx_preference_version = '1.1.1'
androidx_test_runner_version = '1.3.0'
arch_lifecycle_version = '2.2.0'
constraint_layout_version = '2.0.1'
espresso_version = '3.3.0'
exoplayer_version = '2.16.0'
fragment_version = '1.2.5'
glide_version = '4.11.0'
gms_strict_version_matcher_version = '1.0.3'
gradle_version = '3.1.4'
gson_version = '2.8.5'
junit_version = '4.13'
kotlin_version = '1.3.72'
kotlin_coroutines_version = '1.1.0'
multidex_version = '1.0.3'
play_services_auth_version = '18.1.0'
recycler_view_version = '1.1.0'
robolectric_version = '4.2'
test_runner_version = '1.1.0'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.android.gms:strict-version-matcher-plugin:$gms_strict_version_matcher_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
================================================
FILE: common/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.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion rootProject.compileSdkVersion
defaultConfig {
versionCode 1
versionName "1.0"
minSdkVersion rootProject.minSdkVersion
targetSdkVersion rootProject.targetSdkVersion
testOptions.unitTests.includeAndroidResources = true
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
targetCompatibility = '1.8'
}
}
dependencies {
api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
api "androidx.media:media:$androidx_media_version"
api "com.google.code.gson:gson:$gson_version"
// ExoPlayer dependencies
// This allows UAMP to utilize a local version of ExoPlayer, which is particularly
// useful for extending the MediaSession extension, as well as for testing and
// customization. If the ":exoplayer-library-core" project is included, we assume
// the others are included as well.
if (findProject(':exoplayer-library-core') != null) {
api project(':exoplayer-library-core')
api project(':exoplayer-library-ui')
api project(':exoplayer-extension-mediasession')
api project(':exoplayer-extension-cast')
} else {
api "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
api "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
api "com.google.android.exoplayer:extension-mediasession:$exoplayer_version"
api "com.google.android.exoplayer:extension-cast:$exoplayer_version"
}
// Glide dependencies
api "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
// Testing
testImplementation "junit:junit:$junit_version"
testImplementation "org.robolectric:robolectric:$robolectric_version"
}
================================================
FILE: common/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: common/src/main/AndroidManifest.xml
================================================
================================================
FILE: common/src/main/java/com/example/android/uamp/common/MusicServiceConnection.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.common
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.ResultReceiver
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.lifecycle.MutableLiveData
import androidx.media.MediaBrowserServiceCompat
import com.example.android.uamp.common.MusicServiceConnection.MediaBrowserConnectionCallback
import com.example.android.uamp.media.NETWORK_FAILURE
import com.example.android.uamp.media.extensions.id
/**
* Class that manages a connection to a [MediaBrowserServiceCompat] instance, typically a
* [MusicService] or one of its subclasses.
*
* Typically it's best to construct/inject dependencies either using DI or, as UAMP does,
* using [InjectorUtils] in the app module. There are a few difficulties for that here:
* - [MediaBrowserCompat] is a final class, so mocking it directly is difficult.
* - A [MediaBrowserConnectionCallback] is a parameter into the construction of
* a [MediaBrowserCompat], and provides callbacks to this class.
* - [MediaBrowserCompat.ConnectionCallback.onConnected] is the best place to construct
* a [MediaControllerCompat] that will be used to control the [MediaSessionCompat].
*
* Because of these reasons, rather than constructing additional classes, this is treated as
* a black box (which is why there's very little logic here).
*
* This is also why the parameters to construct a [MusicServiceConnection] are simple
* parameters, rather than private properties. They're only required to build the
* [MediaBrowserConnectionCallback] and [MediaBrowserCompat] objects.
*/
class MusicServiceConnection(context: Context, serviceComponent: ComponentName) {
val isConnected = MutableLiveData()
.apply { postValue(false) }
val networkFailure = MutableLiveData()
.apply { postValue(false) }
val rootMediaId: String get() = mediaBrowser.root
val playbackState = MutableLiveData()
.apply { postValue(EMPTY_PLAYBACK_STATE) }
val nowPlaying = MutableLiveData()
.apply { postValue(NOTHING_PLAYING) }
val transportControls: MediaControllerCompat.TransportControls
get() = mediaController.transportControls
private val mediaBrowserConnectionCallback = MediaBrowserConnectionCallback(context)
private val mediaBrowser = MediaBrowserCompat(
context,
serviceComponent,
mediaBrowserConnectionCallback, null
).apply { connect() }
private lateinit var mediaController: MediaControllerCompat
fun subscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) {
mediaBrowser.subscribe(parentId, callback)
}
fun unsubscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) {
mediaBrowser.unsubscribe(parentId, callback)
}
fun sendCommand(command: String, parameters: Bundle?) =
sendCommand(command, parameters) { _, _ -> }
fun sendCommand(
command: String,
parameters: Bundle?,
resultCallback: ((Int, Bundle?) -> Unit)
) = if (mediaBrowser.isConnected) {
mediaController.sendCommand(command, parameters, object : ResultReceiver(Handler()) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
resultCallback(resultCode, resultData)
}
})
true
} else {
false
}
private inner class MediaBrowserConnectionCallback(private val context: Context) :
MediaBrowserCompat.ConnectionCallback() {
/**
* Invoked after [MediaBrowserCompat.connect] when the request has successfully
* completed.
*/
override fun onConnected() {
// Get a MediaController for the MediaSession.
mediaController = MediaControllerCompat(context, mediaBrowser.sessionToken).apply {
registerCallback(MediaControllerCallback())
}
isConnected.postValue(true)
}
/**
* Invoked when the client is disconnected from the media browser.
*/
override fun onConnectionSuspended() {
isConnected.postValue(false)
}
/**
* Invoked when the connection to the media browser failed.
*/
override fun onConnectionFailed() {
isConnected.postValue(false)
}
}
private inner class MediaControllerCallback : MediaControllerCompat.Callback() {
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
playbackState.postValue(state ?: EMPTY_PLAYBACK_STATE)
}
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
// When ExoPlayer stops we will receive a callback with "empty" metadata. This is a
// metadata object which has been instantiated with default values. The default value
// for media ID is null so we assume that if this value is null we are not playing
// anything.
nowPlaying.postValue(
if (metadata?.id == null) {
NOTHING_PLAYING
} else {
metadata
}
)
}
override fun onQueueChanged(queue: MutableList?) {
}
override fun onSessionEvent(event: String?, extras: Bundle?) {
super.onSessionEvent(event, extras)
when (event) {
NETWORK_FAILURE -> networkFailure.postValue(true)
}
}
/**
* Normally if a [MediaBrowserServiceCompat] drops its connection the callback comes via
* [MediaControllerCompat.Callback] (here). But since other connection status events
* are sent to [MediaBrowserCompat.ConnectionCallback], we catch the disconnect here and
* send it on to the other callback.
*/
override fun onSessionDestroyed() {
mediaBrowserConnectionCallback.onConnectionSuspended()
}
}
companion object {
// For Singleton instantiation.
@Volatile
private var instance: MusicServiceConnection? = null
fun getInstance(context: Context, serviceComponent: ComponentName) =
instance ?: synchronized(this) {
instance ?: MusicServiceConnection(context, serviceComponent)
.also { instance = it }
}
}
}
@Suppress("PropertyName")
val EMPTY_PLAYBACK_STATE: PlaybackStateCompat = PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_NONE, 0, 0f)
.build()
@Suppress("PropertyName")
val NOTHING_PLAYING: MediaMetadataCompat = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, "")
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 0)
.build()
================================================
FILE: common/src/main/java/com/example/android/uamp/media/CastMediaItemConverter.kt
================================================
package com.example.android.uamp.media
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import com.example.android.uamp.media.library.JsonSource
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.ext.cast.DefaultMediaItemConverter
import com.google.android.exoplayer2.ext.cast.MediaItemConverter
import com.google.android.exoplayer2.util.MimeTypes
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage
/**
* A [MediaItemConverter] to convert from a [MediaItem] to a Cast [MediaQueueItem].
*
* It adds all audio specific metadata properties and creates a Cast metadata object of type
* [MediaMetadata.MEDIA_TYPE_MUSIC_TRACK].
*
* To create an artwork for Cast we can't use the standard [MediaItem#mediaMetadata#artworkUri]
* because UAMP uses a content provider to serve cached bitmaps. The URIs starting with `content://`
* are useless on a Cast device, so we need to use the original HTTP URI that the [JsonSource]
* stores in the metadata extra with key `JsonSource.ORIGINAL_ARTWORK_URI_KEY`.
*/
internal class CastMediaItemConverter : MediaItemConverter {
private val defaultMediaItemConverter = DefaultMediaItemConverter()
override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
val castMediaMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK)
mediaItem.mediaMetadata.title?.let {
castMediaMetadata.putString(MediaMetadata.KEY_TITLE, it.toString() )
}
mediaItem.mediaMetadata.subtitle?.let {
castMediaMetadata.putString(MediaMetadata.KEY_SUBTITLE, it.toString())
}
mediaItem.mediaMetadata.artist?.let {
castMediaMetadata.putString(MediaMetadata.KEY_ARTIST, it.toString())
}
mediaItem.mediaMetadata.albumTitle?.let {
castMediaMetadata.putString(MediaMetadata.KEY_ALBUM_TITLE, it.toString())
}
mediaItem.mediaMetadata.albumArtist?.let {
castMediaMetadata.putString(MediaMetadata.KEY_ALBUM_ARTIST, it.toString())
}
mediaItem.mediaMetadata.composer?.let {
castMediaMetadata.putString(MediaMetadata.KEY_COMPOSER, it.toString())
}
mediaItem.mediaMetadata.trackNumber?.let{
castMediaMetadata.putInt(MediaMetadata.KEY_TRACK_NUMBER, it)
}
mediaItem.mediaMetadata.discNumber?.let {
castMediaMetadata.putInt(MediaMetadata.KEY_DISC_NUMBER, it)
}
val mediaInfo = MediaInfo.Builder(mediaItem.localConfiguration!!.uri.toString())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(MimeTypes.AUDIO_MPEG)
mediaItem.localConfiguration?.let {
mediaInfo.setContentUrl(it.uri.toString())
}
mediaItem.mediaMetadata.extras?.let { bundle ->
// Use the original artwork URI for Cast.
bundle.getString(JsonSource.ORIGINAL_ARTWORK_URI_KEY)?.let {
castMediaMetadata.addImage(WebImage(Uri.parse(it)))
}
mediaInfo.setStreamDuration(
bundle.getLong(MediaMetadataCompat.METADATA_KEY_DURATION,0))
}
mediaInfo.setMetadata(castMediaMetadata)
return MediaQueueItem.Builder(mediaInfo.build()).build()
}
override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem {
return defaultMediaItemConverter.toMediaItem(mediaQueueItem)
}
}
================================================
FILE: common/src/main/java/com/example/android/uamp/media/MusicService.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.media
import android.app.Notification
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import com.example.android.uamp.media.extensions.album
import com.example.android.uamp.media.extensions.flag
import com.example.android.uamp.media.extensions.id
import com.example.android.uamp.media.extensions.toMediaItem
import com.example.android.uamp.media.extensions.trackNumber
import com.example.android.uamp.media.library.AbstractMusicSource
import com.example.android.uamp.media.library.BrowseTree
import com.example.android.uamp.media.library.JsonSource
import com.example.android.uamp.media.library.MEDIA_SEARCH_SUPPORTED
import com.example.android.uamp.media.library.MusicSource
import com.example.android.uamp.media.library.UAMP_BROWSABLE_ROOT
import com.example.android.uamp.media.library.UAMP_EMPTY_ROOT
import com.example.android.uamp.media.library.UAMP_RECENT_ROOT
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Player.EVENT_MEDIA_ITEM_TRANSITION
import com.google.android.exoplayer2.Player.EVENT_PLAY_WHEN_READY_CHANGED
import com.google.android.exoplayer2.Player.EVENT_POSITION_DISCONTINUITY
import com.google.android.exoplayer2.Player.EVENT_TIMELINE_CHANGED
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.util.Util
import com.google.android.exoplayer2.util.Util.constrainValue
import com.google.android.gms.cast.framework.CastContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
/**
* This class is the entry point for browsing and playback commands from the APP's UI
* and other apps that wish to play music via UAMP (for example, Android Auto or
* the Google Assistant).
*
* Browsing begins with the method [MusicService.onGetRoot], and continues in
* the callback [MusicService.onLoadChildren].
*
* For more information on implementing a MediaBrowserService,
* visit [https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice.html](https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice.html).
*
* This class also handles playback for Cast sessions.
* When a Cast session is active, playback commands are passed to a
* [CastPlayer](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ext/cast/CastPlayer.html),
* otherwise they are passed to an ExoPlayer for local playback.
*/
open class MusicService : MediaBrowserServiceCompat() {
private lateinit var notificationManager: UampNotificationManager
private lateinit var mediaSource: MusicSource
private lateinit var packageValidator: PackageValidator
// The current player will either be an ExoPlayer (for local playback) or a CastPlayer (for
// remote playback through a Cast device).
private lateinit var currentPlayer: Player
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
protected lateinit var mediaSession: MediaSessionCompat
protected lateinit var mediaSessionConnector: MediaSessionConnector
private var currentPlaylistItems: List = emptyList()
private var currentMediaItemIndex: Int = 0
private lateinit var storage: PersistentStorage
/**
* This must be `by lazy` because the source won't initially be ready.
* See [MusicService.onLoadChildren] to see where it's accessed (and first
* constructed).
*/
private val browseTree: BrowseTree by lazy {
BrowseTree(applicationContext, mediaSource)
}
private var isForegroundService = false
private val remoteJsonSource: Uri =
Uri.parse("https://storage.googleapis.com/androiddevelopers/samples_assets/uamp/catalog.json")
private val uAmpAudioAttributes = AudioAttributes.Builder()
.setContentType(C.CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
private val playerListener = PlayerEventListener()
/**
* Configure ExoPlayer to handle audio focus for us.
* See [Player.AudioComponent.setAudioAttributes] for details.
*/
private val exoPlayer: ExoPlayer by lazy {
SimpleExoPlayer.Builder(this).build().apply {
setAudioAttributes(uAmpAudioAttributes, true)
setHandleAudioBecomingNoisy(true)
addListener(playerListener)
}
}
/**
* If Cast is available, create a CastPlayer to handle communication with a Cast session.
*/
private val castPlayer: CastPlayer? by lazy {
try {
val castContext = CastContext.getSharedInstance(this)
CastPlayer(castContext, CastMediaItemConverter()).apply {
setSessionAvailabilityListener(UampCastSessionAvailabilityListener())
addListener(playerListener)
}
} catch (e : Exception) {
// We wouldn't normally catch the generic `Exception` however
// calling `CastContext.getSharedInstance` can throw various exceptions, all of which
// indicate that Cast is unavailable.
// Related internal bug b/68009560.
Log.i(TAG, "Cast is not available on this device. " +
"Exception thrown when attempting to obtain CastContext. " + e.message)
null
}
}
@ExperimentalCoroutinesApi
override fun onCreate() {
super.onCreate()
// Build a PendingIntent that can be used to launch the UI.
val sessionActivityPendingIntent =
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, 0)
}
// Create a new MediaSession.
mediaSession = MediaSessionCompat(this, "MusicService")
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
/**
* In order for [MediaBrowserCompat.ConnectionCallback.onConnected] to be called,
* a [MediaSessionCompat.Token] needs to be set on the [MediaBrowserServiceCompat].
*
* It is possible to wait to set the session token, if required for a specific use-case.
* However, the token *must* be set by the time [MediaBrowserServiceCompat.onGetRoot]
* returns, or the connection will fail silently. (The system will not even call
* [MediaBrowserCompat.ConnectionCallback.onConnectionFailed].)
*/
sessionToken = mediaSession.sessionToken
/**
* The notification manager will use our player and media session to decide when to post
* notifications. When notifications are posted or removed our listener will be called, this
* allows us to promote the service to foreground (required so that we're not killed if
* the main UI is not visible).
*/
notificationManager = UampNotificationManager(
this,
mediaSession.sessionToken,
PlayerNotificationListener()
)
// The media library is built from a remote JSON file. We'll create the source here,
// and then use a suspend function to perform the download off the main thread.
mediaSource = JsonSource(source = remoteJsonSource)
serviceScope.launch {
mediaSource.load()
}
// ExoPlayer will manage the MediaSession for us.
mediaSessionConnector = MediaSessionConnector(mediaSession)
mediaSessionConnector.setPlaybackPreparer(UampPlaybackPreparer())
mediaSessionConnector.setQueueNavigator(UampQueueNavigator(mediaSession))
switchToPlayer(
previousPlayer = null,
newPlayer = if (castPlayer?.isCastSessionAvailable == true) castPlayer!! else exoPlayer
)
notificationManager.showNotificationForPlayer(currentPlayer)
packageValidator = PackageValidator(this, R.xml.allowed_media_browser_callers)
storage = PersistentStorage.getInstance(applicationContext)
}
/**
* This is the code that causes UAMP to stop playing when swiping the activity away from
* recents. The choice to do this is app specific. Some apps stop playback, while others allow
* playback to continue and allow users to stop it with the notification.
*/
override fun onTaskRemoved(rootIntent: Intent) {
saveRecentSongToStorage()
super.onTaskRemoved(rootIntent)
/**
* By stopping playback, the player will transition to [Player.STATE_IDLE] triggering
* [Player.EventListener.onPlayerStateChanged] to be called. This will cause the
* notification to be hidden and trigger
* [PlayerNotificationManager.NotificationListener.onNotificationCancelled] to be called.
* The service will then remove itself as a foreground service, and will call
* [stopSelf].
*/
currentPlayer.stop(/* reset= */true)
}
override fun onDestroy() {
mediaSession.run {
isActive = false
release()
}
// Cancel coroutines when the service is going away.
serviceJob.cancel()
// Free ExoPlayer resources.
exoPlayer.removeListener(playerListener)
exoPlayer.release()
}
/**
* Returns the "root" media ID that the client should request to get the list of
* [MediaItem]s to browse/play.
*/
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
/*
* By default, all known clients are permitted to search, but only tell unknown callers
* about search if permitted by the [BrowseTree].
*/
val isKnownCaller = packageValidator.isKnownCaller(clientPackageName, clientUid)
val rootExtras = Bundle().apply {
putBoolean(
MEDIA_SEARCH_SUPPORTED,
isKnownCaller || browseTree.searchableByUnknownCaller
)
putBoolean(CONTENT_STYLE_SUPPORTED, true)
putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
}
return if (isKnownCaller) {
/**
* By default return the browsable root. Treat the EXTRA_RECENT flag as a special case
* and return the recent root instead.
*/
val isRecentRequest = rootHints?.getBoolean(EXTRA_RECENT) ?: false
val browserRootPath = if (isRecentRequest) UAMP_RECENT_ROOT else UAMP_BROWSABLE_ROOT
BrowserRoot(browserRootPath, rootExtras)
} else {
/**
* Unknown caller. There are two main ways to handle this:
* 1) Return a root without any content, which still allows the connecting client
* to issue commands.
* 2) Return `null`, which will cause the system to disconnect the app.
*
* UAMP takes the first approach for a variety of reasons, but both are valid
* options.
*/
BrowserRoot(UAMP_EMPTY_ROOT, rootExtras)
}
}
/**
* Returns (via the [result] parameter) a list of [MediaItem]s that are child
* items of the provided [parentMediaId]. See [BrowseTree] for more details on
* how this is build/more details about the relationships.
*/
override fun onLoadChildren(
parentMediaId: String,
result: Result>
) {
/**
* If the caller requests the recent root, return the most recently played song.
*/
if (parentMediaId == UAMP_RECENT_ROOT) {
result.sendResult(storage.loadRecentSong()?.let { song -> listOf(song) })
} else {
// If the media source is ready, the results will be set synchronously here.
val resultsSent = mediaSource.whenReady { successfullyInitialized ->
if (successfullyInitialized) {
val children = browseTree[parentMediaId]?.map { item ->
MediaItem(item.description, item.flag)
}
result.sendResult(children)
} else {
mediaSession.sendSessionEvent(NETWORK_FAILURE, null)
result.sendResult(null)
}
}
// If the results are not ready, the service must "detach" the results before
// the method returns. After the source is ready, the lambda above will run,
// and the caller will be notified that the results are ready.
//
// See [MediaItemFragmentViewModel.subscriptionCallback] for how this is passed to the
// UI/displayed in the [RecyclerView].
if (!resultsSent) {
result.detach()
}
}
}
/**
* Returns a list of [MediaItem]s that match the given search query
*/
override fun onSearch(
query: String,
extras: Bundle?,
result: Result>
) {
val resultsSent = mediaSource.whenReady { successfullyInitialized ->
if (successfullyInitialized) {
val resultsList = mediaSource.search(query, extras ?: Bundle.EMPTY)
.map { mediaMetadata ->
MediaItem(mediaMetadata.description, mediaMetadata.flag)
}
result.sendResult(resultsList)
}
}
if (!resultsSent) {
result.detach()
}
}
/**
* Load the supplied list of songs and the song to play into the current player.
*/
private fun preparePlaylist(
metadataList: List,
itemToPlay: MediaMetadataCompat?,
playWhenReady: Boolean,
playbackStartPositionMs: Long
) {
// Since the playlist was probably based on some ordering (such as tracks
// on an album), find which window index to play first so that the song the
// user actually wants to hear plays first.
val initialWindowIndex = if (itemToPlay == null) 0 else metadataList.indexOf(itemToPlay)
currentPlaylistItems = metadataList
currentPlayer.playWhenReady = playWhenReady
currentPlayer.stop()
// Set playlist and prepare.
currentPlayer.setMediaItems(
metadataList.map { it.toMediaItem() }, initialWindowIndex, playbackStartPositionMs)
currentPlayer.prepare()
}
private fun switchToPlayer(previousPlayer: Player?, newPlayer: Player) {
if (previousPlayer == newPlayer) {
return
}
currentPlayer = newPlayer
if (previousPlayer != null) {
val playbackState = previousPlayer.playbackState
if (currentPlaylistItems.isEmpty()) {
// We are joining a playback session. Loading the session from the new player is
// not supported, so we stop playback.
currentPlayer.clearMediaItems()
currentPlayer.stop()
} else if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) {
preparePlaylist(
metadataList = currentPlaylistItems,
itemToPlay = currentPlaylistItems[currentMediaItemIndex],
playWhenReady = previousPlayer.playWhenReady,
playbackStartPositionMs = previousPlayer.currentPosition
)
}
}
mediaSessionConnector.setPlayer(newPlayer)
previousPlayer?.stop(/* reset= */true)
}
private fun saveRecentSongToStorage() {
// Obtain the current song details *before* saving them on a separate thread, otherwise
// the current player may have been unloaded by the time the save routine runs.
if (currentPlaylistItems.isEmpty()) {
return
}
val description = currentPlaylistItems[currentMediaItemIndex].description
val position = currentPlayer.currentPosition
serviceScope.launch {
storage.saveRecentSong(
description,
position
)
}
}
private inner class UampCastSessionAvailabilityListener : SessionAvailabilityListener {
/**
* Called when a Cast session has started and the user wishes to control playback on a
* remote Cast receiver rather than play audio locally.
*/
override fun onCastSessionAvailable() {
switchToPlayer(currentPlayer, castPlayer!!)
}
/**
* Called when a Cast session has ended and the user wishes to control playback locally.
*/
override fun onCastSessionUnavailable() {
switchToPlayer(currentPlayer, exoPlayer)
}
}
private inner class UampQueueNavigator(
mediaSession: MediaSessionCompat
) : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
if (windowIndex < currentPlaylistItems.size) {
return currentPlaylistItems[windowIndex].description
}
return MediaDescriptionCompat.Builder().build()
}
}
private inner class UampPlaybackPreparer : MediaSessionConnector.PlaybackPreparer {
/**
* UAMP supports preparing (and playing) from search, as well as media ID, so those
* capabilities are declared here.
*
* TODO: Add support for ACTION_PREPARE and ACTION_PLAY, which mean "prepare/play something".
*/
override fun getSupportedPrepareActions(): Long =
PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
override fun onPrepare(playWhenReady: Boolean) {
val recentSong = storage.loadRecentSong() ?: return
onPrepareFromMediaId(
recentSong.mediaId!!,
playWhenReady,
recentSong.description.extras
)
}
override fun onPrepareFromMediaId(
mediaId: String,
playWhenReady: Boolean,
extras: Bundle?
) {
mediaSource.whenReady {
val itemToPlay: MediaMetadataCompat? = mediaSource.find { item ->
item.id == mediaId
}
if (itemToPlay == null) {
Log.w(TAG, "Content not found: MediaID=$mediaId")
// TODO: Notify caller of the error.
} else {
val playbackStartPositionMs =
extras?.getLong(MEDIA_DESCRIPTION_EXTRAS_START_PLAYBACK_POSITION_MS, C.TIME_UNSET)
?: C.TIME_UNSET
preparePlaylist(
buildPlaylist(itemToPlay),
itemToPlay,
playWhenReady,
playbackStartPositionMs
)
}
}
}
/**
* This method is used by the Google Assistant to respond to requests such as:
* - Play Geisha from Wake Up on UAMP
* - Play electronic music on UAMP
* - Play music on UAMP
*
* For details on how search is handled, see [AbstractMusicSource.search].
*/
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
mediaSource.whenReady {
val metadataList = mediaSource.search(query, extras ?: Bundle.EMPTY)
if (metadataList.isNotEmpty()) {
preparePlaylist(
metadataList,
metadataList[0],
playWhenReady,
playbackStartPositionMs = C.TIME_UNSET
)
}
}
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit
override fun onCommand(
player: Player,
command: String,
extras: Bundle?,
cb: ResultReceiver?
) = false
/**
* Builds a playlist based on a [MediaMetadataCompat].
*
* TODO: Support building a playlist by artist, genre, etc...
*
* @param item Item to base the playlist on.
* @return a [List] of [MediaMetadataCompat] objects representing a playlist.
*/
private fun buildPlaylist(item: MediaMetadataCompat): List =
mediaSource.filter { it.album == item.album }.sortedBy { it.trackNumber }
}
/**
* Listen for notification events.
*/
private inner class PlayerNotificationListener :
PlayerNotificationManager.NotificationListener {
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
ongoing: Boolean
) {
if (ongoing && !isForegroundService) {
ContextCompat.startForegroundService(
applicationContext,
Intent(applicationContext, this@MusicService.javaClass)
)
startForeground(notificationId, notification)
isForegroundService = true
}
}
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
stopForeground(true)
isForegroundService = false
stopSelf()
}
}
/**
* Listen for events from ExoPlayer.
*/
private inner class PlayerEventListener : Player.Listener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING,
Player.STATE_READY -> {
notificationManager.showNotificationForPlayer(currentPlayer)
if (playbackState == Player.STATE_READY) {
// When playing/paused save the current media item in persistent
// storage so that playback can be resumed between device reboots.
// Search for "media resumption" for more information.
saveRecentSongToStorage()
if (!playWhenReady) {
// If playback is paused we remove the foreground state which allows the
// notification to be dismissed. An alternative would be to provide a
// "close" button in the notification which stops playback and clears
// the notification.
stopForeground(false)
isForegroundService = false
}
}
}
else -> {
notificationManager.hideNotification()
}
}
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.contains(EVENT_POSITION_DISCONTINUITY)
|| events.contains(EVENT_MEDIA_ITEM_TRANSITION)
|| events.contains(EVENT_PLAY_WHEN_READY_CHANGED)) {
currentMediaItemIndex = if (currentPlaylistItems.isNotEmpty()) {
constrainValue(
player.currentMediaItemIndex,
/* min= */ 0,
/* max= */ currentPlaylistItems.size - 1
)
} else 0
}
}
override fun onPlayerError(error: PlaybackException) {
var message = R.string.generic_error;
Log.e(TAG, "Player error: " + error.errorCodeName + " (" + error.errorCode + ")");
if (error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS
|| error.errorCode == PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND) {
message = R.string.error_media_not_found;
}
Toast.makeText(
applicationContext,
message,
Toast.LENGTH_LONG
).show()
}
}
}
/*
* (Media) Session events
*/
const val NETWORK_FAILURE = "com.example.android.uamp.media.session.NETWORK_FAILURE"
/** Content styling constants */
private const val CONTENT_STYLE_BROWSABLE_HINT = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val CONTENT_STYLE_PLAYABLE_HINT = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
private const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
private const val CONTENT_STYLE_LIST = 1
private const val CONTENT_STYLE_GRID = 2
private const val UAMP_USER_AGENT = "uamp.next"
val MEDIA_DESCRIPTION_EXTRAS_START_PLAYBACK_POSITION_MS = "playback_start_position_ms"
private const val TAG = "MusicService"
================================================
FILE: common/src/main/java/com/example/android/uamp/media/PackageValidator.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.media
import android.Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE
import android.Manifest.permission.MEDIA_CONTENT_CONTROL
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
import android.content.pm.PackageManager
import android.content.res.XmlResourceParser
import android.os.Process
import android.support.v4.media.session.MediaSessionCompat
import android.util.Base64
import android.util.Log
import androidx.annotation.XmlRes
import androidx.core.app.NotificationManagerCompat
import androidx.media.MediaBrowserServiceCompat
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
/**
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
*
* The list of allowed signing certificates and their corresponding package names is defined in
* res/xml/allowed_media_browser_callers.xml.
*
* If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
* its signature, this class will print to logcat (INFO level) a message with the proper
* xml tags to add to allow the caller.
*
* For more information, see res/xml/allowed_media_browser_callers.xml.
*/
internal class PackageValidator(context: Context, @XmlRes xmlResId: Int) {
private val context: Context
private val packageManager: PackageManager
private val certificateAllowList: Map
private val platformSignature: String
private val callerChecked = mutableMapOf>()
init {
val parser = context.resources.getXml(xmlResId)
this.context = context.applicationContext
this.packageManager = this.context.packageManager
certificateAllowList = buildCertificateAllowList(parser)
platformSignature = getSystemSignature()
}
/**
* Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
* See [MusicService.onGetRoot] for where this is utilized.
*
* @param callingPackage The package name of the caller.
* @param callingUid The user id of the caller.
* @return `true` if the caller is known, `false` otherwise.
*/
fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
// If the caller has already been checked, return the previous result here.
val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
if (checkedUid == callingUid) {
return checkResult
}
/**
* Because some of these checks can be slow, we save the results in [callerChecked] after
* this code is run.
*
* In particular, there's little reason to recompute the calling package's certificate
* signature (SHA-256) each call.
*
* This is safe to do as we know the UID matches the package's UID (from the check above),
* and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
* be constant until a reboot. (After a reboot then a previously assigned UID could be
* reassigned.)
*/
// Build the caller info for the rest of the checks here.
val callerPackageInfo = buildCallerInfo(callingPackage)
?: throw IllegalStateException("Caller wasn't found in the system?")
// Verify that things aren't ... broken. (This test should always pass.)
if (callerPackageInfo.uid != callingUid) {
throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
}
val callerSignature = callerPackageInfo.signature
val isPackageInAllowList = certificateAllowList[callingPackage]?.signatures?.first {
it.signature == callerSignature
} != null
val isCallerKnown = when {
// If it's our own app making the call, allow it.
callingUid == Process.myUid() -> true
// If it's one of the apps on the allow list, allow it.
isPackageInAllowList -> true
// If the system is making the call, allow it.
callingUid == Process.SYSTEM_UID -> true
// If the app was signed by the same certificate as the platform itself, also allow it.
callerSignature == platformSignature -> true
/**
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
* while it isn't required to allow these apps to connect to a
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
* such as Android TV and the Google Assistant.
*/
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
/**
* If the calling app has a notification listener it is able to retrieve notifications
* and can connect to an active [MediaSessionCompat].
*
* It's not required to allow apps with a notification listener to
* connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility
* with apps such as Wear OS.
*/
NotificationManagerCompat.getEnabledListenerPackages(this.context)
.contains(callerPackageInfo.packageName) -> true
// If none of the previous checks succeeded, then the caller is unrecognized.
else -> false
}
if (!isCallerKnown) {
logUnknownCaller(callerPackageInfo)
}
// Save our work for next time.
callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
return isCallerKnown
}
/**
* Logs an info level message with details of how to add a caller to the allowed callers list
* when the app is debuggable.
*/
private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
if (BuildConfig.DEBUG && callerPackageInfo.signature != null) {
val formattedLog =
context.getString(
R.string.allowed_caller_log,
callerPackageInfo.name,
callerPackageInfo.packageName,
callerPackageInfo.signature
)
Log.i(TAG, formattedLog)
}
}
/**
* Builds a [CallerPackageInfo] for a given package that can be used for all the
* various checks that are performed before allowing an app to connect to a
* [MediaBrowserServiceCompat].
*/
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
val packageInfo = getPackageInfo(callingPackage) ?: return null
val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
val uid = packageInfo.applicationInfo.uid
val signature = getSignature(packageInfo)
val requestedPermissions = packageInfo.requestedPermissions
val permissionFlags = packageInfo.requestedPermissionsFlags
val activePermissions = mutableSetOf()
requestedPermissions?.forEachIndexed { index, permission ->
if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
activePermissions += permission
}
}
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
}
/**
* Looks up the [PackageInfo] for a package name.
* This requests both the signatures (for checking if an app is on the allow list) and
* the app's permissions, which allow for more flexibility in the allow list.
*
* @return [PackageInfo] for the package name or null if it's not found.
*/
@Suppress("deprecation")
@SuppressLint("PackageManagerGetSignatures")
private fun getPackageInfo(callingPackage: String): PackageInfo? =
packageManager.getPackageInfo(
callingPackage,
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
)
/**
* Gets the signature of a given package's [PackageInfo].
*
* The "signature" is a SHA-256 hash of the public key of the signing certificate used by
* the app.
*
* If the app is not found, or if the app does not have exactly one signature, this method
* returns `null` as the signature.
*/
@Suppress("deprecation")
private fun getSignature(packageInfo: PackageInfo): String? =
if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
// Security best practices dictate that an app should be signed with exactly one (1)
// signature. Because of this, if there are multiple signatures, reject it.
null
} else {
val certificate = packageInfo.signatures[0].toByteArray()
getSignatureSha256(certificate)
}
private fun buildCertificateAllowList(parser: XmlResourceParser): Map {
val certificateAllowList = LinkedHashMap()
try {
var eventType = parser.next()
while (eventType != XmlResourceParser.END_DOCUMENT) {
if (eventType == XmlResourceParser.START_TAG) {
val callerInfo = when (parser.name) {
"signing_certificate" -> parseV1Tag(parser)
"signature" -> parseV2Tag(parser)
else -> null
}
callerInfo?.let { info ->
val packageName = info.packageName
val existingCallerInfo = certificateAllowList[packageName]
if (existingCallerInfo != null) {
existingCallerInfo.signatures += callerInfo.signatures
} else {
certificateAllowList[packageName] = callerInfo
}
}
}
eventType = parser.next()
}
} catch (xmlException: XmlPullParserException) {
Log.e(TAG, "Could not read allowed callers from XML.", xmlException)
} catch (ioException: IOException) {
Log.e(TAG, "Could not read allowed callers from XML.", ioException)
}
return certificateAllowList
}
/**
* Parses a v1 format tag. See allowed_media_browser_callers.xml for more details.
*/
private fun parseV1Tag(parser: XmlResourceParser): KnownCallerInfo {
val name = parser.getAttributeValue(null, "name")
val packageName = parser.getAttributeValue(null, "package")
val isRelease = parser.getAttributeBooleanValue(null, "release", false)
val certificate = parser.nextText().replace(WHITESPACE_REGEX, "")
val signature = getSignatureSha256(certificate)
val callerSignature = KnownSignature(signature, isRelease)
return KnownCallerInfo(name, packageName, mutableSetOf(callerSignature))
}
/**
* Parses a v2 format tag. See allowed_media_browser_callers.xml for more details.
*/
private fun parseV2Tag(parser: XmlResourceParser): KnownCallerInfo {
val name = parser.getAttributeValue(null, "name")
val packageName = parser.getAttributeValue(null, "package")
val callerSignatures = mutableSetOf()
var eventType = parser.next()
while (eventType != XmlResourceParser.END_TAG) {
val isRelease = parser.getAttributeBooleanValue(null, "release", false)
val signature = parser.nextText().replace(WHITESPACE_REGEX, "").toLowerCase()
callerSignatures += KnownSignature(signature, isRelease)
eventType = parser.next()
}
return KnownCallerInfo(name, packageName, callerSignatures)
}
/**
* Finds the Android platform signing key signature. This key is never null.
*/
private fun getSystemSignature(): String =
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
getSignature(platformInfo)
} ?: throw IllegalStateException("Platform signature not found")
/**
* Creates a SHA-256 signature given a Base64 encoded certificate.
*/
private fun getSignatureSha256(certificate: String): String {
return getSignatureSha256(Base64.decode(certificate, Base64.DEFAULT))
}
/**
* Creates a SHA-256 signature given a certificate byte array.
*/
private fun getSignatureSha256(certificate: ByteArray): String {
val md: MessageDigest
try {
md = MessageDigest.getInstance("SHA256")
} catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
}
md.update(certificate)
// This code takes the byte array generated by `md.digest()` and joins each of the bytes
// to a string, applying the string format `%02x` on each digit before it's appended, with
// a colon (':') between each of the items.
// For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
return md.digest().joinToString(":") { String.format("%02x", it) }
}
private data class KnownCallerInfo(
internal val name: String,
internal val packageName: String,
internal val signatures: MutableSet
)
private data class KnownSignature(
internal val signature: String,
internal val release: Boolean
)
/**
* Convenience class to hold all of the information about an app that's being checked
* to see if it's a known caller.
*/
private data class CallerPackageInfo(
internal val name: String,
internal val packageName: String,
internal val uid: Int,
internal val signature: String?,
internal val permissions: Set
)
}
private const val TAG = "PackageValidator"
private const val ANDROID_PLATFORM = "android"
private val WHITESPACE_REGEX = "\\s|\\n".toRegex()
================================================
FILE: common/src/main/java/com/example/android/uamp/media/PersistentStorage.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.media
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
import android.support.v4.media.MediaDescriptionCompat
import com.bumptech.glide.Glide
import com.example.android.uamp.media.extensions.asAlbumArtContentUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class PersistentStorage private constructor(val context: Context) {
/**
* Store any data which must persist between restarts, such as the most recently played song.
*/
private var preferences: SharedPreferences = context.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE
)
companion object {
@Volatile
private var instance: PersistentStorage? = null
fun getInstance(context: Context) =
instance ?: synchronized(this) {
instance ?: PersistentStorage(context).also { instance = it }
}
}
suspend fun saveRecentSong(description: MediaDescriptionCompat, position: Long) {
withContext(Dispatchers.IO) {
/**
* After booting, Android will attempt to build static media controls for the most
* recently played song. Artwork for these media controls should not be loaded
* from the network as it may be too slow or unavailable immediately after boot. Instead
* we convert the iconUri to point to the Glide on-disk cache.
*/
val localIconUri = Glide.with(context).asFile().load(description.iconUri)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE).get()
.asAlbumArtContentUri()
preferences.edit()
.putString(RECENT_SONG_MEDIA_ID_KEY, description.mediaId)
.putString(RECENT_SONG_TITLE_KEY, description.title.toString())
.putString(RECENT_SONG_SUBTITLE_KEY, description.subtitle.toString())
.putString(RECENT_SONG_ICON_URI_KEY, localIconUri.toString())
.putLong(RECENT_SONG_POSITION_KEY, position)
.apply()
}
}
fun loadRecentSong(): MediaBrowserCompat.MediaItem? {
val mediaId = preferences.getString(RECENT_SONG_MEDIA_ID_KEY, null)
return if (mediaId == null) {
null
} else {
val extras = Bundle().also {
val position = preferences.getLong(RECENT_SONG_POSITION_KEY, 0L)
it.putLong(MEDIA_DESCRIPTION_EXTRAS_START_PLAYBACK_POSITION_MS, position)
}
MediaBrowserCompat.MediaItem(
MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
.setTitle(preferences.getString(RECENT_SONG_TITLE_KEY, ""))
.setSubtitle(preferences.getString(RECENT_SONG_SUBTITLE_KEY, ""))
.setIconUri(Uri.parse(preferences.getString(RECENT_SONG_ICON_URI_KEY, "")))
.setExtras(extras)
.build(), FLAG_PLAYABLE
)
}
}
}
private const val PREFERENCES_NAME = "uamp"
private const val RECENT_SONG_MEDIA_ID_KEY = "recent_song_media_id"
private const val RECENT_SONG_TITLE_KEY = "recent_song_title"
private const val RECENT_SONG_SUBTITLE_KEY = "recent_song_subtitle"
private const val RECENT_SONG_ICON_URI_KEY = "recent_song_icon_uri"
private const val RECENT_SONG_POSITION_KEY = "recent_song_position"
================================================
FILE: common/src/main/java/com/example/android/uamp/media/UampNotificationManager.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.media
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
const val NOW_PLAYING_CHANNEL_ID = "com.example.android.uamp.media.NOW_PLAYING"
const val NOW_PLAYING_NOTIFICATION_ID = 0xb339 // Arbitrary number used to identify our notification
/**
* A wrapper class for ExoPlayer's PlayerNotificationManager. It sets up the notification shown to
* the user during audio playback and provides track metadata, such as track title and icon image.
*/
internal class UampNotificationManager(
private val context: Context,
sessionToken: MediaSessionCompat.Token,
notificationListener: PlayerNotificationManager.NotificationListener
) {
private var player: Player? = null
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
private val notificationManager: PlayerNotificationManager
private val platformNotificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
init {
val mediaController = MediaControllerCompat(context, sessionToken)
val builder = PlayerNotificationManager.Builder(context, NOW_PLAYING_NOTIFICATION_ID, NOW_PLAYING_CHANNEL_ID)
with (builder) {
setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
setNotificationListener(notificationListener)
setChannelNameResourceId(R.string.notification_channel)
setChannelDescriptionResourceId(R.string.notification_channel_description)
}
notificationManager = builder.build()
notificationManager.setMediaSessionToken(sessionToken)
notificationManager.setSmallIcon(R.drawable.ic_notification)
notificationManager.setUseRewindAction(false)
notificationManager.setUseFastForwardAction(false)
}
fun hideNotification() {
notificationManager.setPlayer(null)
}
fun showNotificationForPlayer(player: Player){
notificationManager.setPlayer(player)
}
private inner class DescriptionAdapter(private val controller: MediaControllerCompat) :
PlayerNotificationManager.MediaDescriptionAdapter {
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
override fun createCurrentContentIntent(player: Player): PendingIntent? =
controller.sessionActivity
override fun getCurrentContentText(player: Player) =
controller.metadata.description.subtitle.toString()
override fun getCurrentContentTitle(player: Player) =
controller.metadata.description.title.toString()
override fun getCurrentLargeIcon(
player: Player,
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
val iconUri = controller.metadata.description.iconUri
return if (currentIconUri != iconUri || currentBitmap == null) {
// Cache the bitmap for the current song so that successive calls to
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = iconUri
serviceScope.launch {
currentBitmap = iconUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
} else {
currentBitmap
}
}
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
// Block on downloading artwork.
Glide.with(context).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(uri)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
.get()
}
}
}
}
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
private val glideOptions = RequestOptions()
.fallback(R.drawable.default_art)
.diskCacheStrategy(DiskCacheStrategy.DATA)
private const val MODE_READ_ONLY = "r"
================================================
FILE: common/src/main/java/com/example/android/uamp/media/extensions/FileExt.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.media.extensions
import android.content.ContentResolver
import android.net.Uri
import java.io.File
/**
* This file contains extension methods for the java.io.File class.
*/
/**
* Returns a Content Uri for the AlbumArtContentProvider
*/
fun File.asAlbumArtContentUri(): Uri {
return Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(this.path)
.build()
}
private const val AUTHORITY = "com.example.android.uamp"
================================================
FILE: common/src/main/java/com/example/android/uamp/media/extensions/JavaLangExt.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.media.extensions
import android.net.Uri
import java.net.URLEncoder
import java.nio.charset.Charset
import java.util.Locale
/**
* This file contains extension methods for the java.lang package.
*/
/**
* Helper method to check if a [String] contains another in a case insensitive way.
*/
fun String?.containsCaseInsensitive(other: String?) =
if (this != null && other != null) {
toLowerCase(Locale.getDefault()).contains(other.toLowerCase(Locale.getDefault()))
} else {
this == other
}
/**
* Helper extension to URL encode a [String]. Returns an empty string when called on null.
*/
inline val String?.urlEncoded: String
get() = if (Charset.isSupported("UTF-8")) {
URLEncoder.encode(this ?: "", "UTF-8")
} else {
// If UTF-8 is not supported, use the default charset.
@Suppress("deprecation")
URLEncoder.encode(this ?: "")
}
/**
* Helper extension to convert a potentially null [String] to a [Uri] falling back to [Uri.EMPTY]
*/
fun String?.toUri(): Uri = this?.let { Uri.parse(it) } ?: Uri.EMPTY
================================================
FILE: common/src/main/java/com/example/android/uamp/media/extensions/MediaMetadataCompatExt.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.media.extensions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaMetadataCompat
import com.example.android.uamp.media.library.JsonSource
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.util.MimeTypes
/**
* Useful extensions for [MediaMetadataCompat].
*/
inline val MediaMetadataCompat.id: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID)
inline val MediaMetadataCompat.title: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_TITLE)
inline val MediaMetadataCompat.artist: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_ARTIST)
inline val MediaMetadataCompat.duration
get() = getLong(MediaMetadataCompat.METADATA_KEY_DURATION)
inline val MediaMetadataCompat.album: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_ALBUM)
inline val MediaMetadataCompat.author: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_AUTHOR)
inline val MediaMetadataCompat.writer: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_WRITER)
inline val MediaMetadataCompat.composer: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_COMPOSER)
inline val MediaMetadataCompat.compilation: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_COMPILATION)
inline val MediaMetadataCompat.date: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_DATE)
inline val MediaMetadataCompat.year: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_YEAR)
inline val MediaMetadataCompat.genre: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_GENRE)
inline val MediaMetadataCompat.trackNumber
get() = getLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER)
inline val MediaMetadataCompat.trackCount
get() = getLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS)
inline val MediaMetadataCompat.discNumber
get() = getLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER)
inline val MediaMetadataCompat.albumArtist: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST)
inline val MediaMetadataCompat.art: Bitmap
get() = getBitmap(MediaMetadataCompat.METADATA_KEY_ART)
inline val MediaMetadataCompat.artUri: Uri
get() = this.getString(MediaMetadataCompat.METADATA_KEY_ART_URI).toUri()
inline val MediaMetadataCompat.albumArt: Bitmap?
get() = getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
inline val MediaMetadataCompat.albumArtUri: Uri
get() = this.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI).toUri()
inline val MediaMetadataCompat.userRating
get() = getLong(MediaMetadataCompat.METADATA_KEY_USER_RATING)
inline val MediaMetadataCompat.rating
get() = getLong(MediaMetadataCompat.METADATA_KEY_RATING)
inline val MediaMetadataCompat.displayTitle: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE)
inline val MediaMetadataCompat.displaySubtitle: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE)
inline val MediaMetadataCompat.displayDescription: String?
get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)
inline val MediaMetadataCompat.displayIcon: Bitmap
get() = getBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON)
inline val MediaMetadataCompat.displayIconUri: Uri
get() = this.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI).toUri()
inline val MediaMetadataCompat.mediaUri: Uri
get() = this.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI).toUri()
inline val MediaMetadataCompat.downloadStatus
get() = getLong(MediaMetadataCompat.METADATA_KEY_DOWNLOAD_STATUS)
/**
* Custom property for storing whether a [MediaMetadataCompat] item represents an
* item that is [MediaItem.FLAG_BROWSABLE] or [MediaItem.FLAG_PLAYABLE].
*/
inline val MediaMetadataCompat.flag
get() = this.getLong(METADATA_KEY_UAMP_FLAGS).toInt()
/**
* Useful extensions for [MediaMetadataCompat.Builder].
*/
// These do not have getters, so create a message for the error.
const val NO_GET = "Property does not have a 'get'"
inline var MediaMetadataCompat.Builder.id: String
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, value)
}
inline var MediaMetadataCompat.Builder.title: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, value)
}
inline var MediaMetadataCompat.Builder.artist: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, value)
}
inline var MediaMetadataCompat.Builder.album: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM, value)
}
inline var MediaMetadataCompat.Builder.duration: Long
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putLong(MediaMetadataCompat.METADATA_KEY_DURATION, value)
}
inline var MediaMetadataCompat.Builder.genre: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_GENRE, value)
}
inline var MediaMetadataCompat.Builder.mediaUri: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, value)
}
inline var MediaMetadataCompat.Builder.albumArtUri: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, value)
}
inline var MediaMetadataCompat.Builder.albumArt: Bitmap?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, value)
}
inline var MediaMetadataCompat.Builder.trackNumber: Long
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, value)
}
inline var MediaMetadataCompat.Builder.trackCount: Long
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, value)
}
inline var MediaMetadataCompat.Builder.displayTitle: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, value)
}
inline var MediaMetadataCompat.Builder.displaySubtitle: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, value)
}
inline var MediaMetadataCompat.Builder.displayDescription: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, value)
}
inline var MediaMetadataCompat.Builder.displayIconUri: String?
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, value)
}
inline var MediaMetadataCompat.Builder.downloadStatus: Long
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putLong(MediaMetadataCompat.METADATA_KEY_DOWNLOAD_STATUS, value)
}
/**
* Custom property for storing whether a [MediaMetadataCompat] item represents an
* item that is [MediaItem.FLAG_BROWSABLE] or [MediaItem.FLAG_PLAYABLE].
*/
inline var MediaMetadataCompat.Builder.flag: Int
@Deprecated(NO_GET, level = DeprecationLevel.ERROR)
get() = throw IllegalAccessException("Cannot get from MediaMetadataCompat.Builder")
set(value) {
putLong(METADATA_KEY_UAMP_FLAGS, value.toLong())
}
fun MediaMetadataCompat.toMediaItemMetadata(): com.google.android.exoplayer2.MediaMetadata {
return with(MediaMetadata.Builder()) {
setTitle(title)
setDisplayTitle(displayTitle)
setAlbumArtist(artist)
setAlbumTitle(album)
setComposer(composer)
setTrackNumber(trackNumber.toInt())
setTotalTrackCount(trackCount.toInt())
setDiscNumber(discNumber.toInt())
setWriter(writer)
setArtworkUri(albumArtUri)
val extras = Bundle()
getString(JsonSource.ORIGINAL_ARTWORK_URI_KEY)?.let {
// album art is a content:// URI. Keep the original for Cast.
extras.putString(
JsonSource.ORIGINAL_ARTWORK_URI_KEY,
getString(JsonSource.ORIGINAL_ARTWORK_URI_KEY)
)
}
extras.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration)
setExtras(extras)
}.build()
}
fun MediaMetadataCompat.toMediaItem(): com.google.android.exoplayer2.MediaItem {
return with(com.google.android.exoplayer2.MediaItem.Builder()) {
setMediaId(mediaUri.toString())
setUri(mediaUri)
setMimeType(MimeTypes.AUDIO_MPEG)
setMediaMetadata(toMediaItemMetadata())
}.build()
}
/**
* Custom property that holds whether an item is [MediaItem.FLAG_BROWSABLE] or
* [MediaItem.FLAG_PLAYABLE].
*/
const val METADATA_KEY_UAMP_FLAGS = "com.example.android.uamp.media.METADATA_KEY_UAMP_FLAGS"
================================================
FILE: common/src/main/java/com/example/android/uamp/media/extensions/PlaybackStateCompatExt.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.media.extensions
import android.os.SystemClock
import android.support.v4.media.session.PlaybackStateCompat
/**
* Useful extension methods for [PlaybackStateCompat].
*/
inline val PlaybackStateCompat.isPrepared
get() = (state == PlaybackStateCompat.STATE_BUFFERING) ||
(state == PlaybackStateCompat.STATE_PLAYING) ||
(state == PlaybackStateCompat.STATE_PAUSED)
inline val PlaybackStateCompat.isPlaying
get() = (state == PlaybackStateCompat.STATE_BUFFERING) ||
(state == PlaybackStateCompat.STATE_PLAYING)
inline val PlaybackStateCompat.isPlayEnabled
get() = (actions and PlaybackStateCompat.ACTION_PLAY != 0L) ||
((actions and PlaybackStateCompat.ACTION_PLAY_PAUSE != 0L) &&
(state == PlaybackStateCompat.STATE_PAUSED))
inline val PlaybackStateCompat.isPauseEnabled
get() = (actions and PlaybackStateCompat.ACTION_PAUSE != 0L) ||
((actions and PlaybackStateCompat.ACTION_PLAY_PAUSE != 0L) &&
(state == PlaybackStateCompat.STATE_BUFFERING ||
state == PlaybackStateCompat.STATE_PLAYING))
inline val PlaybackStateCompat.isSkipToNextEnabled
get() = actions and PlaybackStateCompat.ACTION_SKIP_TO_NEXT != 0L
inline val PlaybackStateCompat.isSkipToPreviousEnabled
get() = actions and PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS != 0L
inline val PlaybackStateCompat.stateName
get() = when (state) {
PlaybackStateCompat.STATE_NONE -> "STATE_NONE"
PlaybackStateCompat.STATE_STOPPED -> "STATE_STOPPED"
PlaybackStateCompat.STATE_PAUSED -> "STATE_PAUSED"
PlaybackStateCompat.STATE_PLAYING -> "STATE_PLAYING"
PlaybackStateCompat.STATE_FAST_FORWARDING -> "STATE_FAST_FORWARDING"
PlaybackStateCompat.STATE_REWINDING -> "STATE_REWINDING"
PlaybackStateCompat.STATE_BUFFERING -> "STATE_BUFFERING"
PlaybackStateCompat.STATE_ERROR -> "STATE_ERROR"
else -> "UNKNOWN_STATE"
}
/**
* Calculates the current playback position based on last update time along with playback
* state and speed.
*/
inline val PlaybackStateCompat.currentPlayBackPosition: Long
get() = if (state == PlaybackStateCompat.STATE_PLAYING) {
val timeDelta = SystemClock.elapsedRealtime() - lastPositionUpdateTime
(position + (timeDelta * playbackSpeed)).toLong()
} else {
position
}
================================================
FILE: common/src/main/java/com/example/android/uamp/media/library/AlbumArtContentProvider.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.media.library
import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import com.bumptech.glide.Glide
import java.io.File
import java.io.FileNotFoundException
import java.util.concurrent.TimeUnit
// The amount of time to wait for the album art file to download before timing out.
const val DOWNLOAD_TIMEOUT_SECONDS = 30L
internal class AlbumArtContentProvider : ContentProvider() {
companion object {
private val uriMap = mutableMapOf()
fun mapUri(uri: Uri): Uri {
val path = uri.encodedPath?.substring(1)?.replace('/', ':') ?: return Uri.EMPTY
val contentUri = Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority("com.example.android.uamp")
.path(path)
.build()
uriMap[contentUri] = uri
return contentUri
}
}
override fun onCreate() = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val context = this.context ?: return null
val remoteUri = uriMap[uri] ?: throw FileNotFoundException(uri.path)
var file = File(context.cacheDir, uri.path)
if (!file.exists()) {
// Use Glide to download the album art.
val cacheFile = Glide.with(context)
.asFile()
.load(remoteUri)
.submit()
.get(DOWNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)
// Rename the file Glide created to match our own scheme.
cacheFile.renameTo(file)
file = cacheFile
}
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun query(
uri: Uri,
projection: Array?,
selection: String?,
selectionArgs: Array?,
sortOrder: String?
): Cursor? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array?
) = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
override fun getType(uri: Uri): String? = null
}
================================================
FILE: common/src/main/java/com/example/android/uamp/media/library/BrowseTree.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.media.library
import android.content.Context
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaMetadataCompat
import com.example.android.uamp.media.MusicService
import com.example.android.uamp.media.R
import com.example.android.uamp.media.extensions.album
import com.example.android.uamp.media.extensions.albumArt
import com.example.android.uamp.media.extensions.albumArtUri
import com.example.android.uamp.media.extensions.artist
import com.example.android.uamp.media.extensions.flag
import com.example.android.uamp.media.extensions.id
import com.example.android.uamp.media.extensions.title
import com.example.android.uamp.media.extensions.trackNumber
import com.example.android.uamp.media.extensions.urlEncoded
/**
* Represents a tree of media that's used by [MusicService.onLoadChildren].
*
* [BrowseTree] maps a media id (see: [MediaMetadataCompat.METADATA_KEY_MEDIA_ID]) to one (or
* more) [MediaMetadataCompat] objects, which are children of that media id.
*
* For example, given the following conceptual tree:
* root
* +-- Albums
* | +-- Album_A
* | | +-- Song_1
* | | +-- Song_2
* ...
* +-- Artists
* ...
*
* Requesting `browseTree["root"]` would return a list that included "Albums", "Artists", and
* any other direct children. Taking the media ID of "Albums" ("Albums" in this example),
* `browseTree["Albums"]` would return a single item list "Album_A", and, finally,
* `browseTree["Album_A"]` would return "Song_1" and "Song_2". Since those are leaf nodes,
* requesting `browseTree["Song_1"]` would return null (there aren't any children of it).
*/
class BrowseTree(
val context: Context,
musicSource: MusicSource,
val recentMediaId: String? = null
) {
private val mediaIdToChildren = mutableMapOf>()
/**
* Whether to allow clients which are unknown (not on the allowed list) to use search on this
* [BrowseTree].
*/
val searchableByUnknownCaller = true
/**
* In this example, there's a single root node (identified by the constant
* [UAMP_BROWSABLE_ROOT]). The root's children are each album included in the
* [MusicSource], and the children of each album are the songs on that album.
* (See [BrowseTree.buildAlbumRoot] for more details.)
*
* TODO: Expand to allow more browsing types.
*/
init {
val rootList = mediaIdToChildren[UAMP_BROWSABLE_ROOT] ?: mutableListOf()
val recommendedMetadata = MediaMetadataCompat.Builder().apply {
id = UAMP_RECOMMENDED_ROOT
title = context.getString(R.string.recommended_title)
albumArtUri = RESOURCE_ROOT_URI +
context.resources.getResourceEntryName(R.drawable.ic_recommended)
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
}.build()
val albumsMetadata = MediaMetadataCompat.Builder().apply {
id = UAMP_ALBUMS_ROOT
title = context.getString(R.string.albums_title)
albumArtUri = RESOURCE_ROOT_URI +
context.resources.getResourceEntryName(R.drawable.ic_album)
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
}.build()
rootList += recommendedMetadata
rootList += albumsMetadata
mediaIdToChildren[UAMP_BROWSABLE_ROOT] = rootList
musicSource.forEach { mediaItem ->
val albumMediaId = mediaItem.album.urlEncoded
val albumChildren = mediaIdToChildren[albumMediaId] ?: buildAlbumRoot(mediaItem)
albumChildren += mediaItem
// Add the first track of each album to the 'Recommended' category
if (mediaItem.trackNumber == 1L) {
val recommendedChildren = mediaIdToChildren[UAMP_RECOMMENDED_ROOT]
?: mutableListOf()
recommendedChildren += mediaItem
mediaIdToChildren[UAMP_RECOMMENDED_ROOT] = recommendedChildren
}
// If this was recently played, add it to the recent root.
if (mediaItem.id == recentMediaId) {
mediaIdToChildren[UAMP_RECENT_ROOT] = mutableListOf(mediaItem)
}
}
}
/**
* Provide access to the list of children with the `get` operator.
* i.e.: `browseTree\[UAMP_BROWSABLE_ROOT\]`
*/
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
/**
* Builds a node, under the root, that represents an album, given
* a [MediaMetadataCompat] object that's one of the songs on that album,
* marking the item as [MediaItem.FLAG_BROWSABLE], since it will have child
* node(s) AKA at least 1 song.
*/
private fun buildAlbumRoot(mediaItem: MediaMetadataCompat): MutableList {
val albumMetadata = MediaMetadataCompat.Builder().apply {
id = mediaItem.album.urlEncoded
title = mediaItem.album
artist = mediaItem.artist
albumArt = mediaItem.albumArt
albumArtUri = mediaItem.albumArtUri.toString()
flag = MediaItem.FLAG_BROWSABLE
}.build()
// Adds this album to the 'Albums' category.
val rootList = mediaIdToChildren[UAMP_ALBUMS_ROOT] ?: mutableListOf()
rootList += albumMetadata
mediaIdToChildren[UAMP_ALBUMS_ROOT] = rootList
// Insert the album's root with an empty list for its children, and return the list.
return mutableListOf().also {
mediaIdToChildren[albumMetadata.id!!] = it
}
}
}
const val UAMP_BROWSABLE_ROOT = "/"
const val UAMP_EMPTY_ROOT = "@empty@"
const val UAMP_RECOMMENDED_ROOT = "__RECOMMENDED__"
const val UAMP_ALBUMS_ROOT = "__ALBUMS__"
const val UAMP_RECENT_ROOT = "__RECENT__"
const val MEDIA_SEARCH_SUPPORTED = "android.media.browse.SEARCH_SUPPORTED"
const val RESOURCE_ROOT_URI = "android.resource://com.example.android.uamp.next/drawable/"
================================================
FILE: common/src/main/java/com/example/android/uamp/media/library/JsonSource.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.media.library
import android.net.Uri
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat.STATUS_NOT_DOWNLOADED
import android.support.v4.media.MediaMetadataCompat
import com.example.android.uamp.media.extensions.album
import com.example.android.uamp.media.extensions.albumArtUri
import com.example.android.uamp.media.extensions.artist
import com.example.android.uamp.media.extensions.displayDescription
import com.example.android.uamp.media.extensions.displayIconUri
import com.example.android.uamp.media.extensions.displaySubtitle
import com.example.android.uamp.media.extensions.displayTitle
import com.example.android.uamp.media.extensions.downloadStatus
import com.example.android.uamp.media.extensions.duration
import com.example.android.uamp.media.extensions.flag
import com.example.android.uamp.media.extensions.genre
import com.example.android.uamp.media.extensions.id
import com.example.android.uamp.media.extensions.mediaUri
import com.example.android.uamp.media.extensions.title
import com.example.android.uamp.media.extensions.trackCount
import com.example.android.uamp.media.extensions.trackNumber
import com.google.android.exoplayer2.C
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.net.URL
import java.util.concurrent.TimeUnit
/**
* Source of [MediaMetadataCompat] objects created from a basic JSON stream.
*
* The definition of the JSON is specified in the docs of [JsonMusic] in this file,
* which is the object representation of it.
*/
internal class JsonSource(private val source: Uri) : AbstractMusicSource() {
companion object {
const val ORIGINAL_ARTWORK_URI_KEY = "com.example.android.uamp.JSON_ARTWORK_URI"
}
private var catalog: List = emptyList()
init {
state = STATE_INITIALIZING
}
override fun iterator(): Iterator = catalog.iterator()
override suspend fun load() {
updateCatalog(source)?.let { updatedCatalog ->
catalog = updatedCatalog
state = STATE_INITIALIZED
} ?: run {
catalog = emptyList()
state = STATE_ERROR
}
}
/**
* Function to connect to a remote URI and download/process the JSON file that corresponds to
* [MediaMetadataCompat] objects.
*/
private suspend fun updateCatalog(catalogUri: Uri): List? {
return withContext(Dispatchers.IO) {
val musicCat = try {
downloadJson(catalogUri)
} catch (ioException: IOException) {
return@withContext null
}
// Get the base URI to fix up relative references later.
val baseUri = catalogUri.toString().removeSuffix(catalogUri.lastPathSegment ?: "")
val mediaMetadataCompats = musicCat.music.map { song ->
// The JSON may have paths that are relative to the source of the JSON
// itself. We need to fix them up here to turn them into absolute paths.
catalogUri.scheme?.let { scheme ->
if (!song.source.startsWith(scheme)) {
song.source = baseUri + song.source
}
if (!song.image.startsWith(scheme)) {
song.image = baseUri + song.image
}
}
val jsonImageUri = Uri.parse(song.image)
val imageUri = AlbumArtContentProvider.mapUri(jsonImageUri)
MediaMetadataCompat.Builder()
.from(song)
.apply {
displayIconUri = imageUri.toString() // Used by ExoPlayer and Notification
albumArtUri = imageUri.toString()
// Keep the original artwork URI for being included in Cast metadata object.
putString(ORIGINAL_ARTWORK_URI_KEY, jsonImageUri.toString())
}
.build()
}.toList()
// Add description keys to be used by the ExoPlayer MediaSession extension when
// announcing metadata changes.
mediaMetadataCompats.forEach { it.description.extras?.putAll(it.bundle) }
mediaMetadataCompats
}
}
/**
* Attempts to download a catalog from a given Uri.
*
* @param catalogUri URI to attempt to download the catalog form.
* @return The catalog downloaded, or an empty catalog if an error occurred.
*/
@Throws(IOException::class)
private fun downloadJson(catalogUri: Uri): JsonCatalog {
val catalogConn = URL(catalogUri.toString())
val reader = BufferedReader(InputStreamReader(catalogConn.openStream()))
return Gson().fromJson(reader, JsonCatalog::class.java)
}
}
/**
* Extension method for [MediaMetadataCompat.Builder] to set the fields from
* our JSON constructed object (to make the code a bit easier to see).
*/
fun MediaMetadataCompat.Builder.from(jsonMusic: JsonMusic): MediaMetadataCompat.Builder {
// The duration from the JSON is given in seconds, but the rest of the code works in
// milliseconds. Here's where we convert to the proper units.
val durationMs = TimeUnit.SECONDS.toMillis(jsonMusic.duration)
id = jsonMusic.id
title = jsonMusic.title
artist = jsonMusic.artist
album = jsonMusic.album
duration = durationMs
genre = jsonMusic.genre
mediaUri = jsonMusic.source
albumArtUri = jsonMusic.image
trackNumber = jsonMusic.trackNumber
trackCount = jsonMusic.totalTrackCount
flag = MediaItem.FLAG_PLAYABLE
// To make things easier for *displaying* these, set the display properties as well.
displayTitle = jsonMusic.title
displaySubtitle = jsonMusic.artist
displayDescription = jsonMusic.album
displayIconUri = jsonMusic.image
// Add downloadStatus to force the creation of an "extras" bundle in the resulting
// MediaMetadataCompat object. This is needed to send accurate metadata to the
// media session during updates.
downloadStatus = STATUS_NOT_DOWNLOADED
// Allow it to be used in the typical builder style.
return this
}
/**
* Wrapper object for our JSON in order to be processed easily by GSON.
*/
class JsonCatalog {
var music: List = ArrayList()
}
/**
* An individual piece of music included in our JSON catalog.
* The format from the server is as specified:
* ```
* { "music" : [
* { "title" : // Title of the piece of music
* "album" : // Album title of the piece of music
* "artist" : // Artist of the piece of music
* "genre" : // Primary genre of the music
* "source" : // Path to the music, which may be relative
* "image" : // Path to the art for the music, which may be relative
* "trackNumber" : // Track number
* "totalTrackCount" : // Track count
* "duration" : // Duration of the music in seconds
* "site" : // Source of the music, if applicable
* }
* ]}
* ```
*
* `source` and `image` can be provided in either relative or
* absolute paths. For example:
* ``
* "source" : "https://www.example.com/music/ode_to_joy.mp3",
* "image" : "ode_to_joy.jpg"
* ``
*
* The `source` specifies the full URI to download the piece of music from, but
* `image` will be fetched relative to the path of the JSON file itself. This means
* that if the JSON was at "https://www.example.com/json/music.json" then the image would be found
* at "https://www.example.com/json/ode_to_joy.jpg".
*/
@Suppress("unused")
class JsonMusic {
var id: String = ""
var title: String = ""
var album: String = ""
var artist: String = ""
var genre: String = ""
var source: String = ""
var image: String = ""
var trackNumber: Long = 0
var totalTrackCount: Long = 0
var duration: Long = C.TIME_UNSET
var site: String = ""
}
================================================
FILE: common/src/main/java/com/example/android/uamp/media/library/MusicSource.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.media.library
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.IntDef
import com.example.android.uamp.media.MusicService
import com.example.android.uamp.media.extensions.album
import com.example.android.uamp.media.extensions.albumArtist
import com.example.android.uamp.media.extensions.artist
import com.example.android.uamp.media.extensions.containsCaseInsensitive
import com.example.android.uamp.media.extensions.genre
import com.example.android.uamp.media.extensions.title
/**
* Interface used by [MusicService] for looking up [MediaMetadataCompat] objects.
*
* Because Kotlin provides methods such as [Iterable.find] and [Iterable.filter],
* this is a convenient interface to have on sources.
*/
interface MusicSource : Iterable {
/**
* Begins loading the data for this music source.
*/
suspend fun load()
/**
* Method which will perform a given action after this [MusicSource] is ready to be used.
*
* @param performAction A lambda expression to be called with a boolean parameter when
* the source is ready. `true` indicates the source was successfully prepared, `false`
* indicates an error occurred.
*/
fun whenReady(performAction: (Boolean) -> Unit): Boolean
fun search(query: String, extras: Bundle): List
}
@IntDef(
STATE_CREATED,
STATE_INITIALIZING,
STATE_INITIALIZED,
STATE_ERROR
)
@Retention(AnnotationRetention.SOURCE)
annotation class State
/**
* State indicating the source was created, but no initialization has performed.
*/
const val STATE_CREATED = 1
/**
* State indicating initialization of the source is in progress.
*/
const val STATE_INITIALIZING = 2
/**
* State indicating the source has been initialized and is ready to be used.
*/
const val STATE_INITIALIZED = 3
/**
* State indicating an error has occurred.
*/
const val STATE_ERROR = 4
/**
* Base class for music sources in UAMP.
*/
abstract class AbstractMusicSource : MusicSource {
@State
var state: Int = STATE_CREATED
set(value) {
if (value == STATE_INITIALIZED || value == STATE_ERROR) {
synchronized(onReadyListeners) {
field = value
onReadyListeners.forEach { listener ->
listener(state == STATE_INITIALIZED)
}
}
} else {
field = value
}
}
private val onReadyListeners = mutableListOf<(Boolean) -> Unit>()
/**
* Performs an action when this MusicSource is ready.
*
* This method is *not* threadsafe. Ensure actions and state changes are only performed
* on a single thread.
*/
override fun whenReady(performAction: (Boolean) -> Unit): Boolean =
when (state) {
STATE_CREATED, STATE_INITIALIZING -> {
onReadyListeners += performAction
false
}
else -> {
performAction(state != STATE_ERROR)
true
}
}
/**
* Handles searching a [MusicSource] from a focused voice search, often coming
* from the Google Assistant.
*/
override fun search(query: String, extras: Bundle): List {
// First attempt to search with the "focus" that's provided in the extras.
val focusSearchResult = when (extras[MediaStore.EXTRA_MEDIA_FOCUS]) {
MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> {
// For a Genre focused search, only genre is set.
val genre = extras[EXTRA_MEDIA_GENRE]
Log.d(TAG, "Focused genre search: '$genre'")
filter { song ->
song.genre == genre
}
}
MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> {
// For an Artist focused search, only the artist is set.
val artist = extras[MediaStore.EXTRA_MEDIA_ARTIST]
Log.d(TAG, "Focused artist search: '$artist'")
filter { song ->
(song.artist == artist || song.albumArtist == artist)
}
}
MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> {
// For an Album focused search, album and artist are set.
val artist = extras[MediaStore.EXTRA_MEDIA_ARTIST]
val album = extras[MediaStore.EXTRA_MEDIA_ALBUM]
Log.d(TAG, "Focused album search: album='$album' artist='$artist")
filter { song ->
(song.artist == artist || song.albumArtist == artist) && song.album == album
}
}
MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> {
// For a Song (aka Media) focused search, title, album, and artist are set.
val title = extras[MediaStore.EXTRA_MEDIA_TITLE]
val album = extras[MediaStore.EXTRA_MEDIA_ALBUM]
val artist = extras[MediaStore.EXTRA_MEDIA_ARTIST]
Log.d(TAG, "Focused media search: title='$title' album='$album' artist='$artist")
filter { song ->
(song.artist == artist || song.albumArtist == artist) && song.album == album
&& song.title == title
}
}
else -> {
// There isn't a focus, so no results yet.
emptyList()
}
}
// If there weren't any results from the focused search (or if there wasn't a focus
// to begin with), try to find any matches given the 'query' provided, searching against
// a few of the fields.
// In this sample, we're just checking a few fields with the provided query, but in a
// more complex app, more logic could be used to find fuzzy matches, etc...
if (focusSearchResult.isEmpty()) {
return if (query.isNotBlank()) {
Log.d(TAG, "Unfocused search for '$query'")
filter { song ->
song.title.containsCaseInsensitive(query)
|| song.genre.containsCaseInsensitive(query)
}
} else {
// If the user asked to "play music", or something similar, the query will also
// be blank. Given the small catalog of songs in the sample, just return them
// all, shuffled, as something to play.
Log.d(TAG, "Unfocused search without keyword")
return shuffled()
}
} else {
return focusSearchResult
}
}
/**
* [MediaStore.EXTRA_MEDIA_GENRE] is missing on API 19. Hide this fact by using our
* own version of it.
*/
private val EXTRA_MEDIA_GENRE
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
MediaStore.EXTRA_MEDIA_GENRE
} else {
"android.intent.extra.genre"
}
}
private const val TAG = "MusicSource"
================================================
FILE: common/src/main/res/drawable/ic_album.xml
================================================
================================================
FILE: common/src/main/res/drawable/ic_recommended.xml
================================================
================================================
FILE: common/src/main/res/menu/main_activity_menu.xml
================================================
================================================
FILE: common/src/main/res/values/strings.xml
================================================
Now PlayingShows what music is currently playing in UAMPSkip to previous trackSkip to next trackPlayPauseCould not locate requested media.The application has encountered an unexpected error.
Caller has a valid certificate, but its package doesn\'t match any expected package for the
given certificate. To allow this caller, add the following to the allowed callers list:\n
\n\t%3$s\n\n
]]>
CastRecommendedAlbums
================================================
FILE: common/src/main/res/xml/allowed_media_browser_callers.xml
================================================
19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00
70:81:1a:3e:ac:fd:2e:83:e1:8d:a9:bf:ed:e5:2d:f1:6c:e9:1f:2e:69:a4:4d:21:f1:8a:b6:69:91:13:07:71
fd:b0:0c:43:db:de:8b:51:cb:31:2a:a8:1d:3b:5f:a1:77:13:ad:b9:4b:28:f5:98:d7:7f:8e:b8:9d:ac:ee:df
1c:a8:dc:c0:be:d3:cb:d8:72:d2:cb:79:12:00:c0:29:2c:a9:97:57:68:a8:2d:67:6b:8b:42:4f:b6:5b:52:95
69:d0:72:16:9a:2c:6b:2f:5a:cc:59:0c:e4:33:a1:1a:c3:df:55:1a:df:ee:5d:5f:63:c0:83:b7:22:76:2e:19
85:cd:59:73:54:1b:e6:f4:77:d8:47:a0:bc:c6:aa:25:27:68:4b:81:9c:d5:96:85:29:66:4c:b0:71:57:b6:fe
19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00
19:75:b2:f1:71:77:bc:89:a5:df:f3:1f:9e:64:a6:ca:e2:81:a5:3d:c1:d1:d5:9b:1d:14:7f:e1:c8:2a:fa:00
f0:fd:6c:5b:41:0f:25:cb:25:c3:b5:33:46:c8:97:2f:ae:30:f8:ee:74:11:df:91:04:80:ad:6b:2d:60:db:83
17:E2:81:11:06:2F:97:A8:60:79:7A:83:70:5B:F8:2C:7C:C0:29:35:56:6D:46:22:BC:4E:CF:EE:1B:EB:F8:15
74:B6:FB:F7:10:E8:D9:0D:44:D3:40:12:58:89:B4:23:06:A6:2C:43:79:D0:E5:A6:62:20:E3:A6:8A:BF:90:E2
================================================
FILE: common/src/test/java/com/example/android/uamp/media/library/MusicSourceTest.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.media.library
import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import com.example.android.uamp.media.extensions.album
import com.example.android.uamp.media.extensions.artist
import com.example.android.uamp.media.extensions.genre
import com.example.android.uamp.media.extensions.id
import com.example.android.uamp.media.extensions.title
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* A small set of Android integration tests for (primarily) [AbstractMusicSource].
*
* The tests all use an extension of [AbstractMusicSource] which is defined at the
* bottom of this file: [TestMusicSource].
*/
@RunWith(RobolectricTestRunner::class)
class MusicSourceTest {
private val musicList = listOf(
MediaMetadataCompat.Builder().apply {
id = "ich_hasse_dich"
title = "Ich hasse dich"
album = "Speechless"
artist = "Jemand"
genre = "Folk"
}.build(),
MediaMetadataCompat.Builder().apply {
id = "about_a_guy"
title = "About a Guy"
album = "Tales from the Render Farm"
artist = "7 Developers and a Pastry Chef"
genre = "Rock"
}.build()
)
/** Variables for testing [MusicSource.whenReady] */
var waiting = true
@Test
fun whenReady_waits() {
val testSource = TestMusicSource(musicList)
waiting = true
testSource.whenReady { success ->
Assert.assertEquals(success, true)
waiting = false
}
Assert.assertEquals(waiting, true)
testSource.prepare()
Assert.assertEquals(waiting, false)
}
@Test
fun whenReady_errorContinues() {
val testSource = TestMusicSource(musicList)
waiting = true
testSource.whenReady { success ->
Assert.assertEquals(success, false)
waiting = false
}
Assert.assertEquals(waiting, true)
testSource.error()
Assert.assertEquals(waiting, false)
}
@Test
fun searchByGenre_matches() {
val testSource = TestMusicSource(musicList)
testSource.prepare()
val searchQuery = "Rock"
val searchExtras = Bundle().apply {
putString(MediaStore.EXTRA_MEDIA_FOCUS, MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE)
putString(MediaStore.EXTRA_MEDIA_GENRE, searchQuery)
}
val result = testSource.search(searchQuery, searchExtras)
Assert.assertEquals(result.size, 1)
Assert.assertEquals(result[0].id, "about_a_guy")
}
@Test
fun searchByMedia_matches() {
val testSource = TestMusicSource(musicList)
testSource.prepare()
val searchQuery = "About a Guy"
val searchExtras = Bundle().apply {
putString(MediaStore.EXTRA_MEDIA_FOCUS, MediaStore.Audio.Media.ENTRY_CONTENT_TYPE)
putString(MediaStore.EXTRA_MEDIA_TITLE, searchQuery)
putString(MediaStore.EXTRA_MEDIA_ALBUM, "Tales from the Render Farm")
putString(MediaStore.EXTRA_MEDIA_ARTIST, "7 Developers and a Pastry Chef")
}
val result = testSource.search(searchQuery, searchExtras)
Assert.assertEquals(result.size, 1)
Assert.assertEquals(result[0].id, "about_a_guy")
}
@Test
fun searchByMedia_nomatches() {
val testSource = TestMusicSource(musicList)
testSource.prepare()
val searchQuery = "Kotlin in 31 Days"
val searchExtras = Bundle().apply {
putString(MediaStore.EXTRA_MEDIA_FOCUS, MediaStore.Audio.Media.ENTRY_CONTENT_TYPE)
putString(MediaStore.EXTRA_MEDIA_TITLE, searchQuery)
putString(MediaStore.EXTRA_MEDIA_ALBUM, "Delegated by Lazy")
putString(MediaStore.EXTRA_MEDIA_ARTIST, "Brainiest Jet")
}
val result = testSource.search(searchQuery, searchExtras)
Assert.assertEquals(result.size, 0)
}
@Test
fun searchByKeyword_fallback() {
val testSource = TestMusicSource(musicList)
testSource.prepare()
val searchQuery = "hasse"
val searchExtras = Bundle.EMPTY
val result = testSource.search(searchQuery, searchExtras)
Assert.assertEquals(result.size, 1)
Assert.assertEquals(result[0].id, "ich_hasse_dich")
}
}
class TestMusicSource(
private val music: List
) : AbstractMusicSource(), Iterable by music {
override suspend fun load() = Unit
fun prepare() {
state = STATE_INITIALIZED
}
fun error() {
state = STATE_ERROR
}
}
================================================
FILE: docs/FAQs.md
================================================
# Frequently Asked Questions
## How can I change the music which UAMP plays?
UAMP reads its [music catalog](https://storage.googleapis.com/androiddevelopers/samples_assets/uamp/catalog.json) from a server.
This contains a list of songs and their metadata in JSON format. To change the music you can create your own
music catalog file, host it on a server and update the catalog URL in
[`MusicService`](https://github.com/android/uamp/blob/6c3ff3779d02f55661c5b9d6032cfac507a8415e/common/src/main/java/com/example/android/uamp/media/MusicService.kt#L127).
Alternatively, you could package your own music catalog and songs inside the app so they can be played without needing an internet connection.
================================================
FILE: docs/FullGuide.md
================================================
# Full Guide to UAMP
The Universal Android Music Player (UAMP) is an example music player app for Android written in [Kotlin](https://kotlinlang.org/). It supports many features including background playback, audio focus handling, multiple platforms (like Wear, TV and Auto) and assistant integration.
It loads a [music catalog](https://storage.googleapis.com/androiddevelopers/samples_assets/uamp/catalog.json) from a remote server and allows the user to browse the albums and songs. Tapping on a song will play it through connected speakers or headphones. Under the hood it uses [ExoPlayer](https://exoplayer.dev/).
If your app's primary goal is to play audio, UAMP is a good place to start.


Screenshots: Browse albums and play a song using UAMP
# Architecture overview
UAMP follows the client/server architecture as [described in the "how to build an audio app" official documentation](https://developer.android.com/guide/topics/media-apps/audio-app/building-an-audio-app).
Here's an architectural overview:

Diagram: Overall architecture of UAMP
# Server architecture
## MusicService
The most important class on the server side is [MusicService](https://github.com/android/uamp/blob/master/common/src/main/java/com/example/android/uamp/media/MusicService.kt) which is a subclass of [MediaBrowserService](https://developer.android.com/reference/android/service/media/MediaBrowserService). MediaBrowserService allows [MediaBrowser](https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowser-client.html) clients from UAMP and other applications to discover the service, connect to the media session and control playback.
UAMP actually uses [MediaBrowserServiceCompat](https://developer.android.com/reference/androidx/media/MediaBrowserServiceCompat.html) - the backwards compatible version of MediaBrowserService provided by the [androidx.media library](https://developer.android.com/reference/androidx/media/package-summary) ([more information](https://medium.com/androiddevelopers/mediabrowserservicecompat-and-the-modern-media-playback-app-7959a5196d90)).
MusicService is responsible for:
* the audio player (provided by [ExoPlayer](https://exoplayer.dev/))
* a [media session](https://developer.android.com/guide/topics/media-apps/working-with-a-media-session) and objects to communicate with it
* maintaining a [notification](https://developer.android.com/guide/topics/ui/notifiers/notifications) which displays information and controls for the current media
* loading [the media catalog](https://storage.googleapis.com/androiddevelopers/samples_assets/uamp/catalog.json) (a JSON file) from a remote URI and providing it to MediaBrowser clients
By keeping the objects responsible for audio playback inside a service it allows audio to be played in the background, decoupling playback from the app's UI.
Here's a more detailed view of MusicService.

Diagram: Detailed view of MusicService
## The media session and controller
A [MediaSession](https://developer.android.com/guide/topics/media-apps/working-with-a-media-session.html#top_of_page) represents an ongoing media playback session. It provides various mechanisms for controlling playback, receiving status updates and retrieving metadata about the current media.
A [MediaController](https://developer.android.com/reference/android/support/v4/media/session/MediaControllerCompat.html) is used to communicate with the media session. It receives media button events and forwards them to the media session. State and metadata updates from the media session are performed through a [MediaController.Callback](https://developer.android.com/reference/android/support/v4/media/session/MediaControllerCompat.Callback.html).

Diagram (from the [official Android documentation](https://developer.android.com/guide/topics/media-apps/media-apps-overview#mediasession-and-mediacontroller)): Shows how MediaController and MediaSession communicate.
UAMP uses two MediaControllers. One on the client side to communicate with the UI (explained later), and one inside MusicService to listen to media session state and metadata changes. This is then used to update the notification.
## Notifying the user

Screenshot: Notification showing information about the current song being played and playback controls
A [notification](https://developer.android.com/guide/topics/ui/notifiers/notifications.html) allows users to see the song being played and to control playback. It's also a mandatory requirement for a [foreground service](https://developer.android.com/guide/components/services#Foreground) and stops MusicService from being killed.
UAMP delegates the display and update of its notification to [`PlayerNotificationManager`](https://exoplayer.dev/doc/reference/index.html?com/google/android/exoplayer2/ui/PlayerNotificationManager.html) provided by ExoPlayer.
## Playing audio
Audio playback is provided by [ExoPlayer](https://exoplayer.dev/). It is responsible for loading media sources via [UampPlaybackPreparer](https://github.com/android/uamp/blob/master/common/src/main/java/com/example/android/uamp/media/UampPlaybackPreparer.kt), playing audio through the available audio hardware (headphones or speakers) and responding to media commands (play, pause, skip etc).
## MediaSessionConnector
[MediaSessionConnector](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.html) is a part of the [media session extension for ExoPlayer](https://github.com/google/ExoPlayer/tree/release-v2/extensions/mediasession). It provides the glue between the ExoPlayer and the MediaSession ([full details here](https://medium.com/google-exoplayer/the-mediasession-extension-for-exoplayer-82b9619deb2d)).
Its key responsibilities are:
* Preparing media sources from URIs using UampPlaybackPreparer
* Sending playback state updates from ExoPlayer to the media session
* Forwarding actions from the media session, such as play, pause and skip, to ExoPlayer
# The User Interface


Screenshot: UAMP user interface screens
The UAMP user interface allows users to:
* browse and play songs
* play and pause songs
* see changes in the underlying player, such as playback duration
* view metadata about the currently playing song including album art, title and artist
UAMP achieves this by using a Model-View-ViewModel architecture. This allows a separation of responsibilities between each layer.

Diagram: Class diagram showing UAMP's Model-View-ViewModel architecture
## Views
UAMP has three main view classes - one [Activity](https://developer.android.com/reference/android/app/Activity) and two [Fragments](https://developer.android.com/guide/components/fragments?hl=en):
* **MainActivity** is responsible for swapping between the two fragments.
* **MediaItemFragment** is for browsing the music catalog. It displays a list of media items which can be either albums or songs. Tapping an album will display a new MediaItemFragment containing the songs within that album. Tapping a song will start playing that song and display the NowPlayingFragment.
* **NowPlayingFragment** shows the song that is currently playing.


Screenshot: MediaItemFragment and NowPlayingFragment are displayed by the MainActivity
## ViewModels
The Activity and Fragments are backed by their own [view models](https://developer.android.com/topic/libraries/architecture/viewmodel). The view model represents the underlying state of the corresponding view. When the model changes, such as when a new song is played, the view is updated to reflect the change. This is achieved using [LiveData](https://developer.android.com/topic/libraries/architecture/livedata) types.
The view models are able to communicate with the MusicService (described in the "Service" section above) using a MusicServiceConnection.
## MusicServiceConnection
MusicServiceConnection is a singleton which connects to the MusicService. It is a wrapper for both the [MediaBrowser](https://developer.android.com/reference/android/support/v4/media/MediaBrowserCompat) and [MediaController](https://developer.android.com/reference/android/support/v4/media/session/MediaControllerCompat) classes.
It is responsible for:
* Connecting to the MusicService using a MediaBrowser
* Receiving a callback from the MusicService to indicate that the MediaBrowser has been successfully connected
* Allowing view models to retrieve the list of albums and songs by subscribing to changes in the MusicService's list of media items via [subscribe](https://developer.android.com/reference/android/support/v4/media/MediaBrowserCompat#subscribe(java.lang.String,%20android.os.Bundle,%20android.support.v4.media.MediaBrowserCompat.SubscriptionCallback))
* Creating a MediaController for the current media session which can be used by view models for:
* controlling the session via transport controls
* retrieving playback state and metadata changes
## Class diagram
The following diagram shows the most important interactions between the UI classes.

Diagram: Important interactions between UI classes
# Summary
The easiest way to get started with UAMP is to clone it, run it and step through the source code using Android Studio. If you have questions or find any parts confusing please [file an issue](https://github.com/android/uamp/issues).
# Further resources
[How to build an audio app](https://developer.android.com/guide/topics/media-apps/audio-app/building-an-audio-app)
[Best practices in media playback - Google I/O 2016](https://www.youtube.com/watch?v=iIKxyDRjecU)
[ExoPlayer](https://exoplayer.dev/)
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
#Thu Jun 18 22:31:29 CEST 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
================================================
FILE: gradle.properties
================================================
#
# 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.
#
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
android.enableJetifier=true
android.useAndroidX=true
org.gradle.jvmargs=-Xmx1536m
# Required by Robolectric.
#android.enableUnitTestBinaryResources=true
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
================================================
FILE: gradlew
================================================
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
================================================
FILE: gradlew.bat
================================================
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: settings.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.
*/
/*
* Allow for customization of the modules to include through an extra file that is not included
* in source control.
*
* *** THERE IS USUALLY NO REASON TO USE THIS ***
*
* The purpose of "extra-settings.gradle" is to allow the use of a local version of the ExoPlayer
* library to be used to build the project. This is useful for testing experimental features
* which have not yet been released, extending the library yourself, or for debugging issues
* with either ExoPlayer or the MediaSession extension.
*
* To make use of this, create a file named "extra-settings.gradle" in the root of the
* project (along side settings.gradle) with the following contents:
*
* gradle.ext.exoplayerRoot = ''
* gradle.ext.exoplayerModulePrefix = 'exoplayer-'
* apply from: new File(gradle.ext.exoplayerRoot, 'core_settings.gradle')
*
* For more information, see: https://github.com/google/ExoPlayer/blob/release-v2/README.md
*/
File extraSettings = new File(rootDir, 'extra-settings.gradle')
if (extraSettings.exists()) {
apply from: extraSettings
}
include ':app', ':common', ':automotive'