Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 08:22:15 +01:00
parent 5b950caea0
commit 477d1afe74
805 changed files with 316919 additions and 2 deletions

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Gradle
.gradle
# Generated files
/build
# IntelliJ
*.iml
*.ipr
*.iws
.idea/
#/.idea/workspace.xml
#/.idea/libraries
# Local configuration file (sdk path, etc)
local.properties
app/keystore.properties
# Keystores
*.keystore
*.jks
# Windows
.DS_Store
# Linux
*~

25
.travis.yml Normal file
View file

@ -0,0 +1,25 @@
language: android
jdk: oraclejdk17
sudo: false
env:
matrix:
- ANDROID_TARGET=android-32
global:
- GRADLE_OPTS="-Xms128m"
android:
components:
# needed build tools
- build-tools-32.0.0
# The SDK version used to compile your project
- android-32
# extra needed components
- extra-android-m2repository
before_script:
script:
- ./gradlew assembleRelease lintRelease testDebugUnitTest

202
CHANGELOG.md Normal file
View file

@ -0,0 +1,202 @@
Changelog
---------
Version 3.1.0
-------------
Minor update, primarily aimed at ensuring Kore remains up to date with the latest Android versions, while also addressing a few bugs along the way.
Version 3.0.0
-------------
Major changes in this version, bringing Kore up to date with the current Android platform (well, except for migrating to Kotlin, which to be honest wouldn't bring that much benefits, given that only replacing the syntax without taking advantage of the newer facilities provided by Kotlin/Android wouldn't bring major benefits).
Anyway, the major changes since the last version are:
- Migrate to Android's Material 3 UI guidelines, namely:
- Review themes and colors, adding support for light and dark modes based on the device's settings
- Add support for dynamic colors, which change depending on the user's wallpaper (on Android 12+)
- Use images with round corners and update buttons, text boxes, etc to current standards
- Update media notifications and integrate them with Android's Media Session.
Note: if the media notification disappears after a few minutes, even though something is playing on Kodi, that's caused by the some aggressive battery optimization settings which forcefully stop the notification. This happens with some manufaturers that don't follow Android's guidelines, in a futile and artificial attempt to extent the battery life (Xiaomi, OnePlus, Samsung, etc), and the solution is to not restrict Kore's battery usage (the way to do it depends on the specific device, more info can be obtained at https://dontkillmyapp.com/)
- Redesign most of Kore's screens, the major changes being:
- The Remote screen, adding the current playback state and better media controls allow for more control of what's playing
- The Now Playing panel, adding the current playback state and media control buttons
- The Movies, TV Shows, Music and Addons information screens, doing a complete redesign, particularly on the button actions section. The IMDb link has been removed as it was seldom broken and replaced with a generic Google search, where appropriate.
Note, the "Play locally" function is now called "Stream", which is more appropriate and concise
- The Artist details screen, to show the artist albums beneath its general information
- Review all icons, updating them to current ones
- Improve showing the connection status (connecting, not connected or connected) on the various screens
- Make top app bar collapsable, and on the remote screen allow the background image to use up all the screen
- Improve the screen transitions
- Fix access to media storage in current Android versions
- General code cleaning, remove deprecated code and update current library versions
- Lots of other small bug fixes
Version 2.5.3
-------------
- Add support for SendToKodi
- Add support for sharing from Twitch
- Allow to disable direct share on a per host basis
- Bug fixes, specifically issues with thumbnails on Kodi Matrix and errors that prevented downloading files from Kodi
Version 2.5.1
-------------
- Add support for sharing from Arte video (The European Culture Channel) to Kodi
- Add support for sharing from Amazon Prime Videos
- Fix download of media files
- Support local play of items in the "Files" section
- Various improvments and bug fixes: Fix "Play from here" in the "Files" section, refresh of playlists in the remote, sharing local filenames with spaces in the name, support for self-signed certificates, remember last used tab, etc.
Version 2.5.0
-------------
- Include search option in PVR section
- Allow sorting PVR recordings and optionally hiding watched items
- Added support for sharing local files to Kodi, either by going into the side menu option "Local Files", or by choosing Kore as the share target (when accessing the file, for instance via a file browser)
- Allow changing Kore's language in Settings
- Add support to sharing from Soundcloud to Kodi
- New sort option for albums, movies and tv shows: by year
- Added new color themes (Sunrise and Sunset) and tweaked the others
- Scroll titles, when these are too long to fit (in the Now Playing and Info screens)
- Kore now shows all the available playlists, even when nothing's playing
- Update notifications to use the default Android style
- Movie ratings added to movie list
- New translations (Korean, Slovak)
- Bug fixes and UI tweaks
Version 2.4.7
-------------
- Improved addons list
- Enable direct sharing of a URL to a specific host
- Bug fixes and UI tweaks
Version 2.4.4
-------------
- Enable playing movies locally on device
- Add new setting to use skip steps instead of seeking in the notification
- Improve sharing from youtube
- Bug fixes and UI tweaks
Version 2.3.3
-------------
- Fixes for Android Oreo
- Make control pad scalable on smaller devices
- Improve showing text with markup codes on the Now Playing and PVR sections
- Handle playlists shared from YouTube app
- Option to use volume hardware keys anywhere inside Kore
- Bug fixes
Version 2.3.2
-------------
- New slide up panel with media controls on information screens
- Added new Favourites section to the navigation side panel
- Remote bottom bar shortcuts now configurable through Settings
- Added watched indicator to movies and tv shows list
- Various UI tweaks, including new colors and icons
- Bug fixes
Version 2.2.0
-------------
- Redesign settings screen
- Redesign TV show details to include next episodes and seasons list
- Show volume level on the Now Playing screen
- Added various new sort options on movies, TV shows and albums lists
- Improved songs list, showing the artist name on each song
- Support sharing to Kodi plain video urls
- New option: keep screen on when using the remote
- Various UI tweaks
- Bug fixes
Version 2.1.0
-------------
- Add songs tab on Music section and Artist section and support for showing songs without album or artist
- Add addon browsing
- Show artist details when an artist is selected from the list
- New option: pause playing when in a phone call (requires permission to phone state on Android versions < 6.0)
- New option: keep the remote above the lockscreen
- Support for playing Vimeo URLs
- Improve library syncing
- Various UI tweaks
- Bug fixes
Version 2.0.0
-------------
- PVR support
- New animations on transitions from list to details screens
- Added option to play/queue entire album, artist or genre
- Improve library syncing
- Various tweaks
- Bug fixes
Version 1.5.0
-------------
- D-pad buttons can skip forward/backward when media is playing (if EventServer is enabled in the media center's configuration, and accessible in Kodi)
- Added new screen to show all cast in movies and tv shows
- Added vibration option to d-pad buttons
- Add stop button to remote screen
- Fix youtube share behaviour
- Czech translation
- Simplified Chinese translation
- Russian translation
- Basque translation
- Spanish translation
- Bug fixes
Version 1.4.0
-------------
- Added support for sharing from youtube app to Kodi
- Visual tweaks
- Bug fixes
Version 1.3.0
-------------
- Remote redesign
- File browsing
- Fix Wake on Lan issues
- Bug fixes
Version 1.2.1
-------------
- Fix subtitle selection
Version 1.2.0
-------------
- Prepare for Official Remote status
Version 1.1
-----------
- Brazilian Portuguese translation (by Rafael Rosário @rafaelricado)
Version 1.1.0
-------------
- Replace Codec button with Context button on remote. Codec info is now available through a long click on Info button
- Added now playing notification
- Use hardware volume keys to control volume
- Italian translation (by Enrico Strocchi)
- Improved music library sync
- Visual tweaks
Version 1.0.1
-------------
- Fixed bug with In-app purchase key that was crashing Settings screen
Version 1.0.0
-------------
- New options to sort movies and tv shows
- Bulgarian translation (by NEOhidra)
- German translation (by jonas2515)
Version 0.9.2
-------------
- Added new actions in remote: update/clean library and toggle fullscreen
- French translation (thanks Kowalski!)
- Bug fixes and visual tweaks
Version 0.9.1
-------------
- Improved library sync;
- Automatically switch to remove after media start;
- Visual tweaks.
Version 0.9.0
-------------
- First version

41
Dockerfile Normal file
View file

@ -0,0 +1,41 @@
# Usage: docker build -t kore:latest .
# docker run -it -v $(pwd):/opt/kore kore:latest bash
# gradle
FROM ubuntu:20.04
# Install Java
ARG JDK_VERSION=17
RUN apt-get update && \
apt-get install -y --no-install-recommends openjdk-${JDK_VERSION}-jdk && \
apt-get install -y --no-install-recommends git wget unzip
# Install Gradle
# https://services.gradle.org/distributions/
ARG GRADLE_VERSION=8.4
ARG GRADLE_DIST=bin
RUN cd /opt && \
wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-${GRADLE_DIST}.zip && \
unzip gradle*.zip && \
ls -d */ | sed 's/\/*$//g' | xargs -I{} mv {} gradle && \
rm gradle*.zip
# Install Android SDK and build-tools
# https://developer.android.com/studio#command-tools
ARG ANDROID_SDK_VERSION=8512546
ENV ANDROID_SDK_ROOT /opt/android/sdk
RUN mkdir -p ${ANDROID_SDK_ROOT}/tools && \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_VERSION}_latest.zip && \
unzip *tools*linux*.zip -d ${ANDROID_SDK_ROOT} && \
rm /commandlinetools*linux*.zip
# Install Android build-tools (should match version in ./app/build.gradle)
ARG ANDROID_BUILD_TOOLS_VERSION=32.0.0
RUN yes Y | /opt/android/sdk/cmdline-tools/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --install "build-tools;${ANDROID_BUILD_TOOLS_VERSION}"
RUN yes Y | /opt/android/sdk/cmdline-tools/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses
# Set the environment variables
ENV JAVA_HOME /usr/lib/jvm/java-${JDK_VERSION}-openjdk-amd64
ENV GRADLE_HOME /opt/gradle
ENV PATH ${PATH}:${GRADLE_HOME}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/bin:${ANDROID_SDK_ROOT}/tools/bin:${ANDROID_SDK_ROOT}/platform-tools:${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}
WORKDIR /opt/kore

202
LICENSE.txt Normal file
View file

@ -0,0 +1,202 @@
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 [yyyy] [name of copyright owner]
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.

View file

@ -1,3 +1,79 @@
# kore
[![Build App](https://github.com/xbmc/Kore/actions/workflows/build.yml/badge.svg)](https://github.com/xbmc/Kore/actions/workflows/build.yml)
[![Translations](https://kodi.weblate.cloud/widgets/kodi-remotes/-/kore/svg-badge.svg)](https://kodi.weblate.cloud/engage/kodi-remotes/)
Android Remote for Kodi Media Center
<a href="https://play.google.com/store/apps/details?id=org.xbmc.kore" target="_blank">
<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" height="80"/>
</a>
<a href="https://f-droid.org/packages/org.xbmc.kore/" target="_blank">
<img src="https://f-droid.org/badge/get-it-on.png" height="80"/>
</a>
# Kore - Kodi/XBMC remote for Android
GitHub repository for the [Kore][1] Android app.
Kore is the official remote for [Kodi](http://kodi.tv/), and aims to be a simple and easy to use remote.
## Building
1. Make sure you have a working [Android build system](http://developer.android.com/sdk/installing/studio-build.html);
2. The version of Android SDK and Build Tools needed is specified in app/build.gradle. Make sure you have them installed;
3. Install the version of [Android support library](http://developer.android.com/tools/support-library/setup.html) that is specified in app/gradle (dependencies section);
4. Git pull
5. Gradle should be able to fetch all the other needed libraries.
## Testing
1. Make sure you are able to build Kore as described in the previous section.
2. To run the local tests see [README](https://github.com/xbmc/Kore/blob/master/app/src/test/README.md)
3. To run the instrumented tests see [README](https://github.com/xbmc/Kore/blob/master/app/src/androidTest/README.md)
We use [GitHub Actions](https://github.com/xbmc/Kore/actions) to automatically build and run the local tests for each pull request.
## Using Docker
1. Make sure you have a working [Docker installation](https://docs.docker.com/docker-for-windows/install/);
2. Check out the repository
3. Build the container image: `docker build -t kore:latest .`
4. Start container: `docker run -it -v $(pwd):/opt/kore kore:latest bash`
For listing all tasks, run `gradle tasks`, for building the app, execute `gradle assembleRelease`.
If you want to run tests, execute `gradle testDebugUnitTest`.
## Credits
**Libraries used**
- [Jackson](https://github.com/FasterXML/jackson)
- [Picasso](http://square.github.io/picasso/)
- [OkHttp](http://square.github.io/okhttp/)
- [EventBus](https://github.com/greenrobot/EventBus)
- [JmDNS](https://github.com/jmdns/jmdns)
- [ExpandableTextView](https://github.com/Blogcat/Android-ExpandableTextView)
## Links
- [Kodi forum thread](http://forum.kodi.tv/forumdisplay.php?fid=129)
- [F-Droid](https://f-droid.org/repository/browse/?fdid=org.xbmc.kore)
- [Google Play][1]
- [Google+ community](https://plus.google.com/communities/115506510322045554124)
## License
Copyright 2022 XBMC Foundation
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.
Google Play and the Google Play logo are trademarks of Google Inc.
[1]: https://play.google.com/store/apps/details?id=org.xbmc.kore

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

173
app/build.gradle Normal file
View file

@ -0,0 +1,173 @@
apply plugin: 'com.android.application'
def getVersionName = { ->
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'describe', '--tags', '--always'
standardOutput = stdout
}
return stdout.toString().trim()
}
android {
compileSdk 34
defaultConfig {
applicationId "org.xbmc.kore"
minSdkVersion 24
targetSdkVersion 34
versionCode 33
versionName = getVersionName()
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
def supportedLocales = ["en",
"af-za", "ast", "be-by", "bg", "ca", "cs", "da-dk", "de",
"es", "es-MX", "eu", "fi", "fr", "hr", "hu", "it", "iw", "ja",
"ko", "lt", "nl", "pl", "pt", "pt-BR", "ru", "sk", "sl", "zh-CN"]
buildConfigField "String[]", "SUPPORTED_LOCALES", "new String[]{\""+
supportedLocales.join("\",\"")+"\"}"
}
signingConfigs {
release {
if (System.getenv("KODI_ANDROID_STORE_FILE") != null) {
keyAlias System.getenv("KODI_ANDROID_KEY_ALIAS")
keyPassword System.getenv("KODI_ANDROID_KEY_PASSWORD")
storeFile file(System.getenv("KODI_ANDROID_STORE_FILE"))
storePassword System.getenv("KODI_ANDROID_STORE_PASSWORD")
enableV1Signing true
enableV2Signing true
enableV3Signing true
}
}
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
unitTests {
includeAndroidResources = true
}
}
buildTypes {
release {
if (System.getenv("KODI_ANDROID_STORE_FILE") != null) {
signingConfig signingConfigs.release
zipAlignEnabled true
}
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
viewBinding true
}
packagingOptions {
resources {
excludes += ['META-INF/DEPENDENCIES', 'META-INF/NOTICE', 'META-INF/LICENSE', 'META-INF/LICENSE.txt', 'META-INF/NOTICE.txt']
}
}
lint {
// Too much trouble keeping all translations in sync
disable 'MissingTranslation'
}
bundle {
language {
enableSplit = false
}
}
namespace 'org.xbmc.kore'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
ext {
supportLibVersion = '28.0.0'
}
dependencies {
implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation('androidx.core:core-google-shortcuts:1.1.0') {
exclude group:'com.google.android.gms'
}
implementation 'androidx.media:media:1.6.0'
implementation "androidx.viewpager2:viewpager2:1.1.0-beta02"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.fragment:fragment:1.6.2"
implementation "androidx.fragment:fragment-ktx:1.6.2"
// Dependency resolution
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2"
implementation "androidx.lifecycle:lifecycle-viewmodel:2.6.2"
// Jackson v2.13 kept. v2.14 and later require minSDK >= 26
// https://github.com/FasterXML/jackson/wiki/Jackson-Releases
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
// Ignore the new version warning as it refers to v2.71... which is older on maven
implementation 'com.squareup.picasso:picasso:2.8'
implementation 'org.greenrobot:eventbus:3.3.1'
implementation 'org.jmdns:jmdns:3.5.8'
implementation 'at.blogc:expandabletextview:1.0.5'
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
implementation 'org.nanohttpd:nanohttpd:2.3.1'
implementation fileTree(dir: 'libs', include: ['*.jar'])
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.legacy:legacy-support-v13:1.0.0'
androidTestImplementation 'org.hamcrest:hamcrest-library:2.2'
androidTestImplementation 'junit:junit:4.13.2'
androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.8.1'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.test.ext:junit:1.1.5'
debugImplementation 'junit:junit:4.13.2'
}
def adb = android.getAdbExecutable().toString()
afterEvaluate {
tasks.register('grantAnimationPermissionDev', Exec) {
dependsOn installDebug
doFirst {
println("Executing: $adb shell pm grant $android.defaultConfig.applicationId android.permission.SET_ANIMATION_SCALE")
commandLine "$adb shell pm grant $android.defaultConfig.applicationId android.permission.SET_ANIMATION_SCALE".split(' ')
}
}
tasks.each { task ->
if (task.name.startsWith('connectedDebugAndroidTest')) {
task.dependsOn grantAnimationPermissionDev
}
}
}
/**
* Makes sure assets are copied before running the unit tests
*/
tasks.configureEach { task ->
if (task.name.contains("testDebugUnitTest")) {
task.dependsOn assembleDebug
}
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs << '-Xlint:unchecked' // << '-Xlint:deprecation'
}

44
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,44 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /opt/android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# Don't obfuscate for now. Obfuscation decreases apk size by about 300k
-dontobfuscate
# Picasso
-dontwarn com.squareup.okhttp.**
# okio via OkHttp
-dontwarn okio.**
# Jackson
-dontskipnonpubliclibraryclassmembers
-keepattributes EnclosingMethod, Signature
#-keep class org.codehaus.** { *; }
-keepnames class com.fasterxml.jackson.** { *; }
-dontwarn com.fasterxml.jackson.databind.**
# EventBus
-keepclassmembers class ** {
public void onEvent*(**);
}
# SearchView
-keep class androidx.appcompat.widget.SearchView { *; }
#JmDNS
-dontwarn org.slf4j.*
# 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 *;
#}

View file

@ -0,0 +1,19 @@
Integration tests that need to be executed on an Android device.
## Run tests
You can run the tests as follows:
### Android Studio
1. Select build variant "instrumentationTestDebug"
2. Set the [Project view](https://developer.android.com/studio/projects/index.html) to Android
3. Right-click on the directory "androidTest" and select "Run tests"
### Commandline
Run the following command from the top of the project:
./gradlew connectedInstrumentationTestDebugAndroidTest
This will run the tests on all connected devices in parallel

View file

@ -0,0 +1,372 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testhelpers;
import android.app.Activity;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.view.View;
import android.widget.AutoCompleteTextView;
import android.widget.TextView;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.contrib.RecyclerViewActions;
import com.sothree.slidinguppanel.SlidingUpPanelLayout;
import org.hamcrest.Matcher;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.action.ViewActions;
import org.xbmc.kore.ui.widgets.NowPlayingPanel;
import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu;
import static androidx.test.espresso.Espresso.pressBack;
import static androidx.test.espresso.action.ViewActions.clearText;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.CoreMatchers.anything;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.instanceOf;
import static org.xbmc.kore.testhelpers.action.ViewActions.clearFocus;
public class EspressoTestUtils {
public static void rotateDevice(Activity activity) {
int orientation
= activity.getResources().getConfiguration().orientation;
activity.setRequestedOrientation(
(orientation == Configuration.ORIENTATION_PORTRAIT) ?
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE :
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
/**
* Clicks a menu item regardless if it is in the overflow menu or
* visible as icon in the action bar
* @param activity
* @param name Name of the menu item in the overflow menu
* @param resourceId Resource identifier of the menu item
*/
public static void clickMenuItem(Activity activity, String name, int resourceId) {
try {
onView(withId(resourceId)).perform(click());
} catch (NoMatchingViewException e) {
openActionBarOverflowOrOptionsMenu(activity);
//Use onData as item might not be visible in the View without scrolling
onData(allOf(
Matchers.withMenuTitle(name)))
.perform(click());
}
}
/**
* Clicks the arrow button in the toolbar when its function is collapsing a view. For instance,
* collapse the search view in the toolbar.
*/
public static void clickToolbarCollapseButton() {
/**
* The image button in the toolbar used as home/collapse/back button has no ID we can use.
* In appcompat v7 the arrow button in the toolbar used to collapse a search view has a
* description "Collapse". We use this to find the button in the view and perform the click
* action.
*/
onView(withContentDescription("Collapse")).perform(click());
}
/**
* Clicks the button with given resourceId and checks if the
* button is displayed. As we occasionally use the same identifiers
* in multiple fragments, we need to check if it is visible as well
* to prevent Espresso from finding multiple views that match the
* resource identifier.
* @param resourceId
*/
public static void clickButton(int resourceId) {
onView(allOf(withId(resourceId), isDisplayed())).perform(click());
}
/**
* Clicks on the search menu item and enters the given search query
* @param activity
* @param query
*/
public static void enterSearchQuery(Activity activity, String query) {
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
onView(isAssignableFrom(AutoCompleteTextView.class))
.perform(click(), typeText(query), closeSoftKeyboard());
onView(isRoot()).perform(clearFocus());
}
/**
* Clicks on the search menu item and clears the search query by entering the empty string
* @param activity
*/
public static void clearSearchQuery(Activity activity) {
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
onView(isAssignableFrom(AutoCompleteTextView.class))
.perform(click(), clearText(), closeSoftKeyboard());
}
/**
* Clears the search query by pressing the X button
* @param activity
*/
public static void clearSearchQueryXButton(Activity activity) {
try {
onView(withId(R.id.search_close_btn)).perform(click());
} catch (NoMatchingViewException e) {
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
onView(withId(R.id.search_close_btn)).perform(click());
}
Espresso.closeSoftKeyboard();
}
/**
* Performs a click on an item in an adapter view, such as GridView or ListView
* @param position
* @param resourceId of adapter view holding the item that should be clicked
*/
public static void clickAdapterViewItem(int position, int resourceId) {
onData(anything()).inAdapterView(allOf(withId(resourceId), isDisplayed()))
.atPosition(position).perform(click());
}
public static void clickRecyclerViewItem(int position, int resourceId) {
onView(withId(resourceId)).perform(RecyclerViewActions.actionOnItemAtPosition(position, click()));
}
public static void clickRecyclerViewItem(String text, int resourceId) {
ViewInteraction viewInteraction = onView(allOf(withId(resourceId),
hasDescendant(withText(containsString(text))),
isDisplayed()));
viewInteraction.perform(RecyclerViewActions.scrollTo(hasDescendant(withText(containsString(text)))));
viewInteraction.perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(containsString(text))),
click()));
}
/**
* Checks that SearchView contains the given text
* @param query text that SearchView should contain
*/
public static void checkTextInSearchQuery(String query) {
onView(isAssignableFrom(AutoCompleteTextView.class)).check(matches(withText(query)));
}
/**
* Checks that the list contains item(s) matching search query
* @param query text each element must contain
* @param listSize amount of elements expected in list
* @param resourceId resource identifier or list view
*/
public static void checkListMatchesSearchQuery(String query, int listSize, int resourceId) {
onView(isRoot()).perform(ViewActions.waitForView(resourceId, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return v.isShown();
}
}, 10000));
onView(allOf(withId(resourceId), isDisplayed()))
.check(matches(Matchers.withOnlyMatchingDataItems(hasDescendant(withText(containsString(query))))));
checkRecyclerViewListsize(listSize, resourceId);
}
/**
* Checks that the list size matches the given list size
* @param listSize amount of elements expected in list
*/
public static void checkRecyclerViewListsize(int listSize, int resourceId) {
onView(allOf(withId(resourceId), isDisplayed()))
.check(matches(Matchers.withRecyclerViewSize(listSize)));
}
/**
* Checks that the list size matches the given list size
* @param listSize amount of elements expected in list
*/
public static void checkListViewSize(int listSize, int resourceId) {
onView(allOf(withId(resourceId), isDisplayed()))
.check(matches(Matchers.withListViewSize(listSize)));
}
/**
* Checks if search action view does not exist in the current view hierarchy
*/
public static void checkSearchMenuCollapsed() {
onView(isAssignableFrom(AutoCompleteTextView.class)).check(doesNotExist());
}
/**
* Returns the current active activity. Use this when the originally started activity
* started a new activity and you need the reference to the new activity.
* @return reference to the current active activity
*/
public static Activity getActivity() {
final Activity[] activity = new Activity[1];
onView(allOf(withId(android.R.id.content), isDisplayed())).perform(new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isAssignableFrom(View.class);
}
@Override
public String getDescription() {
return "getting current activity";
}
@Override
public void perform(UiController uiController, View view) {
if (view.getContext() instanceof Activity) {
activity[0] = ((Activity)view.getContext());
}
}
});
return activity[0];
}
/**
* Clicks on tab that contains the text given by stringResourceId.
* @param stringResourceId text displayed in Tab that should be clicked
*/
public static void clickTab(int stringResourceId) {
onView(withId(R.id.tab_layout)).perform(ViewActions.setCurrentViewPagerItem(stringResourceId));
}
/**
* Clicks the album tab in the music activity
*/
public static void clickAlbumsTab() {
clickTab(R.string.albums);
}
/**
* Clicks the artists tab in the music activity
*/
public static void clickArtistsTab() {
clickTab(R.string.artists);
}
/**
* Clicks the genres tab in the music activity
*/
public static void clickGenresTab() {
clickTab(R.string.genres);
}
/**
* Clicks the music videos tab in the music activity
*/
public static void clickMusicVideosTab() {
clickTab(R.string.videos);
}
/**
* Selects an item in the list, then presses back and checks the action bar title
* @param item number (0 is first item) of the item that should be pressed
* @param listResourceId Resource identifier of the AdapterView
* @param actionbarTitle title that should be displayed in the action bar after pressing back
*/
public static void selectListItemPressBackAndCheckActionbarTitle(int item,
int listResourceId,
String actionbarTitle) {
EspressoTestUtils.clickRecyclerViewItem(item, listResourceId);
pressBack();
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(actionbarTitle)));
}
/**
* Selects an item in the list, then presses back and checks the action bar title
* @param itemText the text the item that must be pressed should contain
* @param listResourceId Resource identifier of the AdapterView
* @param actionbarTitle title that should be displayed in the action bar after pressing back
*/
public static void selectListItemPressBackAndCheckActionbarTitle(String itemText,
int listResourceId,
String actionbarTitle) {
EspressoTestUtils.clickRecyclerViewItem(itemText, listResourceId);
pressBack();
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(containsString(actionbarTitle))));
}
/**
* Selects an item in the list, then rotates the device and checks the action bar title
* @param itemText the text the item that must be pressed should contain
* @param listResourceId Resource identifier of the AdapterView
* @param actionbarTitle title that should be displayed in the action bar after rotating
*/
public static void selectListItemRotateDeviceAndCheckActionbarTitle(String itemText,
int listResourceId,
final String actionbarTitle,
Activity activity) {
EspressoTestUtils.clickRecyclerViewItem(itemText, listResourceId);
EspressoTestUtils.rotateDevice(activity);
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(containsString(actionbarTitle))));
}
/**
* Selects an item in the list and then checks the action bar title
* @param itemText the text the item that must be pressed should contain
* @param listResourceId Resource identifier of the AdapterView
* @param actionbarTitle title that should be displayed in the action bar after selecting item
*/
public static void selectListItemAndCheckActionbarTitle(String itemText,
int listResourceId,
String actionbarTitle) {
EspressoTestUtils.clickRecyclerViewItem(itemText, listResourceId);
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(actionbarTitle)));
}
/**
* Waits for 10 seconds till panel has given state.
*
* @param panelState desired state of panel
*/
public static void waitForPanelState(final int panelState) {
onView(isRoot()).perform(ViewActions.waitForView(R.id.now_playing_panel, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((NowPlayingPanel)v).getPanelState() == panelState;
}
}, 10000));
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testhelpers;
import androidx.loader.app.LoaderManager;
import androidx.test.espresso.IdlingResource;
public class LoaderIdlingResource implements IdlingResource {
private ResourceCallback mResourceCallback;
private LoaderManager loaderManager;
public LoaderIdlingResource(LoaderManager loaderManager) {
this.loaderManager = loaderManager;
}
@Override
public String getName() {
return LoaderIdlingResource.class.getName();
}
@Override
public boolean isIdleNow() {
boolean idle = !loaderManager.hasRunningLoaders();
if (idle && mResourceCallback != null) {
mResourceCallback.onTransitionToIdle();
}
return idle;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
mResourceCallback = resourceCallback;
}
}

View file

@ -0,0 +1,217 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testhelpers;
import android.database.Cursor;
import android.view.MenuItem;
import android.view.View;
import android.widget.ListView;
import android.widget.SeekBar;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.matcher.BoundedMatcher;
import androidx.test.espresso.matcher.CursorMatchers;
import org.hamcrest.BaseMatcher;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.xbmc.kore.ui.widgets.HighlightButton;
import org.xbmc.kore.ui.widgets.RepeatModeButton;
import org.xbmc.kore.utils.UIUtils;
public class Matchers {
public static MenuItemTitleMatcher withMenuTitle(String title) {
return new MenuItemTitleMatcher(title);
}
public static class MenuItemTitleMatcher extends BaseMatcher<Object> {
private final String title;
public MenuItemTitleMatcher(String title) { this.title = title; }
@Override
public boolean matches(Object o) {
if (o instanceof MenuItem) {
return ((MenuItem) o).getTitle().equals(title);
}
return false;
}
@Override
public void describeTo(Description description) { }
}
public static Matcher<View> withListViewSize(final int size) {
return new TypeSafeMatcher<View>() {
@Override public boolean matchesSafely(final View view) {
return (view instanceof ListView) &&
((ListView) view).getAdapter().getCount() == size;
}
@Override public void describeTo(final Description description) {
description.appendText("List should have " + size + " item(s)");
}
};
}
public static Matcher<View> withRecyclerViewSize(final int size) {
return new TypeSafeMatcher<View>() {
@Override
protected boolean matchesSafely(View view) {
return (view instanceof RecyclerView) &&
((RecyclerView) view).getAdapter().getItemCount() == size;
}
@Override
public void describeTo(Description description) {
description.appendText("RecyclerView should have " + size + " item(s)");
}
};
}
public static Matcher<View> withOnlyMatchingDataItems(final Matcher<View> dataMatcher) {
return new TypeSafeMatcher<View>() {
@Override
protected boolean matchesSafely(View view) {
if (!(view instanceof RecyclerView))
return false;
RecyclerView recyclerView = (RecyclerView) view;
for (int i = 0; i < recyclerView.getChildCount(); i++) {
RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(i);
if (! dataMatcher.matches(viewHolder.itemView)) {
return false;
}
}
return true;
}
@Override
public void describeTo(Description description) {
description.appendText("withOnlyMatchingDataItems: ");
dataMatcher.describeTo(description);
}
};
}
public static Matcher<Object> withItemContent(final Matcher<String> textMatcher) {
return new BoundedMatcher<Object, Cursor>(Cursor.class) {
@Override
protected boolean matchesSafely(Cursor item) {
for (int i = 0; i < item.getColumnCount();i++) {
switch (item.getType(i)) {
case Cursor.FIELD_TYPE_STRING:
if (CursorMatchers.withRowString(i, textMatcher).matches(item))
return true;
break;
}
}
return false;
}
@Override
public void describeTo(Description description) {
description.appendText("withItemContent: ");
textMatcher.describeTo(description);
}
};
}
public static Matcher<View> withProgress(final int progress) {
return new BoundedMatcher<View, SeekBar>(SeekBar.class) {
@Override
protected boolean matchesSafely(SeekBar item) {
return item.getProgress() == progress;
}
@Override
public void describeTo(Description description) {
description.appendText("expected: " + progress);
}
};
}
public static Matcher<View> withProgress(final String progress) {
return new BoundedMatcher<View, TextView>(TextView.class) {
@Override
protected boolean matchesSafely(TextView item) {
return progress.contentEquals(item.getText());
}
@Override
public void describeTo(Description description) {
description.appendText("expected: " + progress);
}
};
}
public static Matcher<View> withProgressGreaterThanOrEqual(final String time) {
return new BoundedMatcher<View, SeekBar>(SeekBar.class) {
@Override
protected boolean matchesSafely(SeekBar item) {
return item.getProgress() >= UIUtils.timeToSeconds(time);
}
@Override
public void describeTo(Description description) {
description.appendText("expected progress greater than " + time);
}
};
}
public static Matcher<View> withProgressGreaterThan(final int progress) {
return new BoundedMatcher<View, SeekBar>(SeekBar.class) {
@Override
protected boolean matchesSafely(SeekBar item) {
return item.getProgress() > progress;
}
@Override
public void describeTo(Description description) {
description.appendText("expected progress greater than " + progress);
}
};
}
public static Matcher<View> withHighlightState(final boolean highlight) {
return new BoundedMatcher<View, HighlightButton>(HighlightButton.class) {
@Override
protected boolean matchesSafely(HighlightButton item) {
return item.isHighlighted();
}
@Override
public void describeTo(Description description) {
description.appendText("expected: " + highlight);
}
};
}
public static Matcher<View> withRepeatMode(final RepeatModeButton.MODE mode) {
return new BoundedMatcher<View, RepeatModeButton>(RepeatModeButton.class) {
@Override
protected boolean matchesSafely(RepeatModeButton item) {
return item.getMode() == mode;
}
@Override
public void describeTo(Description description) {
description.appendText("expected: " + mode.name());
}
};
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.testhelpers;
import org.xbmc.kore.testutils.tcpserver.handlers.InputHandler;
import static junit.framework.Assert.assertTrue;
import static org.xbmc.kore.tests.ui.AbstractTestClass.getInputHandler;
public class TestUtils {
/**
* Tests if the event received at the server matches the given
* method name and action
* @param methodName name of the method that should be received serverside.
* @param executeAction name of the action that should be received serverside. May be null if the input does not specify an action.
*/
public static void testHTTPEvent(String methodName, String executeAction) {
InputHandler inputHandler = getInputHandler();
assertTrue(inputHandler != null);
String methodNameReceived = inputHandler.getMethodName();
assertTrue(methodNameReceived != null);
assertTrue(methodNameReceived.contentEquals(methodName));
if (executeAction != null) {
String actionReceived = inputHandler.getAction();
assertTrue(actionReceived != null);
assertTrue(actionReceived.contentEquals(executeAction));
}
}
}

View file

@ -0,0 +1,148 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testhelpers;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.IBinder;
import android.util.Log;
import android.view.Gravity;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.preference.PreferenceManager;
import androidx.test.rule.ActivityTestRule;
import org.xbmc.kore.R;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.host.HostManager;
import org.xbmc.kore.provider.MediaProvider;
import org.xbmc.kore.ui.AbstractTabsFragment;
import org.xbmc.kore.ui.sections.hosts.HostFragmentManualConfiguration;
import org.xbmc.kore.utils.LogUtils;
import java.lang.reflect.Method;
import static org.xbmc.kore.ui.generic.NavigationDrawerFragment.PREF_USER_LEARNED_DRAWER;
public class Utils {
private static final String TAG = LogUtils.makeLogTag(Utils.class);
private static final String ANIMATION_PERMISSION = "android.permission.SET_ANIMATION_SCALE";
private static final float DISABLED = 0.0f;
private static final float DEFAULT = 1.0f;
public static void closeDrawer(final Activity activity) throws Throwable {
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
DrawerLayout drawerLayout = (DrawerLayout) activity.findViewById(R.id.drawer_layout);
drawerLayout.closeDrawers();
}
});
}
public static void openDrawer(final ActivityTestRule<?> activityTestRule) throws Throwable {
activityTestRule.runOnUiThread(new Runnable() {
@Override
public void run() {
DrawerLayout drawerLayout = (DrawerLayout) activityTestRule.getActivity().findViewById(R.id.drawer_layout);
drawerLayout.openDrawer(Gravity.LEFT);
}
});
DrawerLayout drawerLayout = (DrawerLayout) activityTestRule.getActivity().findViewById(R.id.drawer_layout);
while(true) {
if (drawerLayout.isDrawerOpen(Gravity.LEFT))
return;
}
}
public static void switchHost(final Context context, Activity activity, final HostInfo hostInfo) {
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
HostManager.getInstance(context).switchHost(hostInfo);
}
});
}
public static void clearSharedPreferences(Context context) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().clear().commit();
context.getSharedPreferences(AbstractTabsFragment.PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit().clear().commit();
}
public static void setLearnedAboutDrawerPreference(Context context, boolean learned) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(PREF_USER_LEARNED_DRAWER, learned);
editor.commit();
}
public static void setUseEventServerPreference(Context context, boolean use) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(HostFragmentManualConfiguration.HOST_USE_EVENT_SERVER, use);
editor.commit();
}
public static void setupMediaProvider(Context context) {
MediaProvider mediaProvider = new MediaProvider();
mediaProvider.setContext(context);
mediaProvider.onCreate();
}
public static void disableAnimations(Context context) {
int permStatus = context.checkCallingOrSelfPermission(ANIMATION_PERMISSION);
if (permStatus == PackageManager.PERMISSION_GRANTED) {
setSystemAnimationsScale(DISABLED);
}
}
public static void enableAnimations(Context context) {
int permStatus = context.checkCallingOrSelfPermission(ANIMATION_PERMISSION);
if (permStatus == PackageManager.PERMISSION_GRANTED) {
setSystemAnimationsScale(DEFAULT);
} else {
LogUtils.LOGD(TAG, "disableAnimations: permission " + ANIMATION_PERMISSION + " not granted");
}
}
private static void setSystemAnimationsScale(float animationScale) {
try {
Class<?> windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub");
Method asInterface = windowManagerStubClazz.getDeclaredMethod("asInterface", IBinder.class);
Class<?> serviceManagerClazz = Class.forName("android.os.ServiceManager");
Method getService = serviceManagerClazz.getDeclaredMethod("getService", String.class);
Class<?> windowManagerClazz = Class.forName("android.view.IWindowManager");
Method setAnimationScales = windowManagerClazz.getDeclaredMethod("setAnimationScales", float[].class);
Method getAnimationScales = windowManagerClazz.getDeclaredMethod("getAnimationScales");
IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window");
Object windowManagerObj = asInterface.invoke(null, windowManagerBinder);
float[] currentScales = (float[]) getAnimationScales.invoke(windowManagerObj);
for (int i = 0; i < currentScales.length; i++) {
currentScales[i] = animationScale;
}
setAnimationScales.invoke(windowManagerObj, new Object[]{currentScales});
} catch (Exception e) {
Log.e("SystemAnimations", "Could not change animation scale to " + animationScale + " :'(");
}
}
}

View file

@ -0,0 +1,49 @@
/**
* Copyright (C) 2014 Subito.it S.r.l (www.subito.it)
*
* 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 org.xbmc.kore.testhelpers.action;
import android.view.View;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import org.hamcrest.Matcher;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static org.hamcrest.Matchers.allOf;
public class ClearFocus implements ViewAction {
@Override
public Matcher<View> getConstraints() {
return allOf(isDisplayed(), isAssignableFrom(View.class));
}
@Override
public String getDescription() {
return "Clear focus on the given view";
}
@Override
public void perform(UiController uiController, View view) {
view.clearFocus();
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.testhelpers.action;
import android.graphics.Rect;
import androidx.core.widget.NestedScrollView;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.matcher.ViewMatchers;
import androidx.test.espresso.util.HumanReadables;
import android.view.View;
import org.hamcrest.Matcher;
import org.xbmc.kore.utils.LogUtils;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast;
import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.anyOf;
/**
* Modified version of {@link androidx.test.espresso.action.ScrollToAction} to support
* NestedScrollView.
* TODO Check future versions of {@link androidx.test.espresso.action.ScrollToAction} to see if support for NestedScrollView has been added
*/
public class NestedScrollTo implements ViewAction {
private final static String TAG = LogUtils.makeLogTag(NestedScrollTo.class);
@Override
public Matcher<View> getConstraints() {
return allOf(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), ViewMatchers.isDescendantOfA(anyOf(
isAssignableFrom(NestedScrollView.class))));
}
@Override
public String getDescription() {
return "nested scroll to";
}
@Override
public void perform(UiController uiController, View view) {
if (isDisplayingAtLeast(90).matches(view)) {
LogUtils.LOGI(TAG, "View is already displayed. Returning.");
return;
}
Rect rect = new Rect();
view.getDrawingRect(rect);
if (!view.requestRectangleOnScreen(rect, true /* immediate */)) {
LogUtils.LOGW(TAG, "Scrolling to view was requested, but none of the parents scrolled.");
}
uiController.loopMainThreadUntilIdle();
if (!isDisplayingAtLeast(90).matches(view)) {
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(new RuntimeException(
"Scrolling to view was attempted, but the view is not displayed"))
.build();
}
}
}

View file

@ -0,0 +1,199 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testhelpers.action;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.action.MotionEvents;
import androidx.test.espresso.action.Press;
import androidx.test.espresso.util.HumanReadables;
import androidx.test.espresso.util.TreeIterables;
import androidx.viewpager2.widget.ViewPager2;
import android.view.View;
import android.widget.SeekBar;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.util.concurrent.TimeoutException;
import static androidx.test.espresso.action.ViewActions.actionWithAssertions;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import com.google.android.material.tabs.TabLayout;
public final class ViewActions {
/**
* Returns an action that clears the focus on the view.
* <br/>
* View constraints:
* <ul>
* <li>must be displayed on screen</li>
* </ul>
*/
public static ViewAction clearFocus() {
return actionWithAssertions(new ClearFocus());
}
/**
* Returns an action that scrolls to the view in a nested scroll view.<br>
* <br>
* View preconditions:
* <ul>
* <li>must be a descendant of NestedScrollView
* <li>must have visibility set to View.VISIBLE
* <ul></ul>
*/
public static ViewAction nestedScrollTo() {
return actionWithAssertions(new NestedScrollTo());
}
public interface CheckStatus {
boolean check(View v);
}
/**
* ViewAction that waits until view with viewId becomes visible
* @param viewId Resource identifier of view item that must be checked
* @param checkStatus called when viewId has been found to check its status. If return value
* is true waitForView will stop, false it will continue until timeout is exceeded
* @param millis amount of time to wait for view to become visible
* @return
*/
public static ViewAction waitForView(final int viewId, final CheckStatus checkStatus, final long millis) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isRoot();
}
@Override
public String getDescription() {
return "Searches for view with id: " + viewId + " and tests its status using CheckStatus, using timeout " + millis + " ms.";
}
@Override
public void perform(UiController uiController, View view) {
final long endTime = System.currentTimeMillis() + millis;
do {
for (View child : TreeIterables.breadthFirstViewTraversal(view)) {
if (child.getId() == viewId) {
if (checkStatus.check(child)) {
return;
}
}
}
uiController.loopMainThreadForAtLeast(50);
} while (System.currentTimeMillis() < endTime);
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(new TimeoutException())
.build();
}
};
}
public static ViewAction slideSeekBar(final int progress) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return new TypeSafeMatcher<View>() {
@Override
protected boolean matchesSafely(View item) {
return item instanceof SeekBar;
}
@Override
public void describeTo(Description description) {
description.appendText("is a SeekBar.");
}
};
}
@Override
public String getDescription() {
return "Slides seekbar to progress position " + progress;
}
@Override
public void perform(UiController uiController, View view) {
SeekBar seekBar = (SeekBar) view;
int[] seekBarPos = {0,0};
view.getLocationOnScreen(seekBarPos);
float[] startPos = {seekBarPos[0], seekBarPos[1]};
MotionEvents.DownResultHolder downResultHolder =
MotionEvents.sendDown(uiController, startPos,
Press.PINPOINT.describePrecision());
while(seekBar.getProgress() < progress) {
startPos[0]++;
MotionEvents.sendMovement(uiController, downResultHolder.down, startPos);
uiController.loopMainThreadForAtLeast(10);
}
MotionEvents.sendUp(uiController, downResultHolder.down, startPos);
}
};
}
public static ViewAction setCurrentViewPagerItem(final int pageTitleResourceId) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return new TypeSafeMatcher<View>() {
@Override
protected boolean matchesSafely(View item) {
return item instanceof TabLayout;
}
@Override
public void describeTo(Description description) {
description.appendText("is a SeekBar.");
}
};
}
@Override
public String getDescription() {
return null;
}
@Override
public void perform(UiController uiController, View view) {
TabLayout tabLayout = (TabLayout) view;
String expectedTitle = view.getResources().getString(pageTitleResourceId);
for(int i = 0; i < tabLayout.getTabCount(); i++) {
if (expectedTitle.contentEquals(tabLayout.getTabAt(i).getText())) {
tabLayout.getTabAt(i).select();
return;
}
}
}
};
}
}

View file

@ -0,0 +1,190 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceManager;
import androidx.test.espresso.IdlingRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.ActivityTestRule;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.runner.RunWith;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.host.HostConnection;
import org.xbmc.kore.testhelpers.LoaderIdlingResource;
import org.xbmc.kore.testhelpers.Utils;
import org.xbmc.kore.testutils.Database;
import org.xbmc.kore.testutils.tcpserver.MockTcpServer;
import org.xbmc.kore.testutils.tcpserver.handlers.AddonsHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.ApplicationHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.InputHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.JSONConnectionHandlerManager;
import org.xbmc.kore.testutils.tcpserver.handlers.JSONRPCHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.PlayerHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.PlaylistHandler;
import org.xbmc.kore.ui.sections.hosts.HostFragmentManualConfiguration;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
@SuppressLint("IgnoreWithoutReason")
@RunWith(AndroidJUnit4.class)
@Ignore
abstract public class AbstractTestClass<T extends AppCompatActivity> {
private static final String TAG = LogUtils.makeLogTag(AbstractTestClass.class);
abstract protected ActivityTestRule<T> getActivityTestRule();
/**
* Method that can be used to change the shared preferences.
* This will be called before each test after clearing the settings
* in {@link #setUp()}
*/
abstract protected void setSharedPreferences(Context context);
private LoaderIdlingResource loaderIdlingResource;
private ActivityTestRule<T> activityTestRule;
private static MockTcpServer server;
private static JSONConnectionHandlerManager manager;
private static PlayerHandler playerHandler;
private static ApplicationHandler applicationHandler;
private static InputHandler inputHandler;
private static PlaylistHandler playlistHandler;
private int kodiMajorVersion = HostInfo.DEFAULT_KODI_VERSION_MAJOR;
private HostInfo hostInfo;
@BeforeClass
public static void setupMockTCPServer() throws Throwable {
playerHandler = new PlayerHandler();
applicationHandler = new ApplicationHandler();
inputHandler = new InputHandler();
playlistHandler = new PlaylistHandler();
manager = new JSONConnectionHandlerManager();
manager.addHandler(playerHandler);
manager.addHandler(applicationHandler);
manager.addHandler(inputHandler);
manager.addHandler(new AddonsHandler());
manager.addHandler(playlistHandler);
manager.addHandler(new JSONRPCHandler());
server = new MockTcpServer(manager);
server.start();
}
@Before
public void setUp() throws Throwable {
activityTestRule = getActivityTestRule();
final Context context = activityTestRule.getActivity();
if (context == null)
throw new RuntimeException("Could not get context. Maybe activity failed to start?");
Utils.clearSharedPreferences(context);
//Prevent drawer from opening when we start a new activity
Utils.setLearnedAboutDrawerPreference(context, true);
//Allow each test to change the shared preferences
setSharedPreferences(context);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean useEventServer = prefs.getBoolean(HostFragmentManualConfiguration.HOST_USE_EVENT_SERVER, false);
hostInfo = Database.addHost(context, server.getHostName(),
HostConnection.PROTOCOL_TCP, HostInfo.DEFAULT_HTTP_PORT,
server.getPort(), useEventServer, kodiMajorVersion);
loaderIdlingResource = new LoaderIdlingResource(activityTestRule.getActivity().getSupportLoaderManager());
IdlingRegistry.getInstance().register(loaderIdlingResource);
Utils.disableAnimations(context);
Utils.setupMediaProvider(context);
Database.fill(hostInfo, context, context.getContentResolver());
Utils.switchHost(context, activityTestRule.getActivity(), hostInfo);
//Relaunch the activity for the changes (Host selection, preference changes, and database fill) to take effect
activityTestRule.finishActivity();
activityTestRule.launchActivity(new Intent());
}
@After
public void tearDown() throws Exception {
if ( loaderIdlingResource != null )
IdlingRegistry.getInstance().unregister(loaderIdlingResource);
applicationHandler.reset();
playerHandler.reset();
Context context = activityTestRule.getActivity();
Database.flush(context.getContentResolver());
Utils.enableAnimations(context);
}
@AfterClass
public static void cleanup() throws IOException {
server.shutdown();
}
protected T getActivity() {
if (activityTestRule != null) {
return activityTestRule.getActivity();
}
return null;
}
/**
* Use this to set the major version of Kodi.
* <br/>
* NOTE: be sure to call this before {@link #setUp()} is called to have the version correctly
* set in the database.
* @param kodiMajorVersion
*/
protected void setKodiMajorVersion(int kodiMajorVersion) {
this.kodiMajorVersion = kodiMajorVersion;
}
public static JSONConnectionHandlerManager getConnectionHandlerManager() {
return manager;
}
public static PlayerHandler getPlayerHandler() {
return playerHandler;
}
public static ApplicationHandler getApplicationHandler() {
return applicationHandler;
}
public static InputHandler getInputHandler() {
return inputHandler;
}
public static PlaylistHandler getPlaylistHandler() {
return playlistHandler;
}
}

View file

@ -0,0 +1,212 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.addons;
import android.content.Context;
import android.view.View;
import android.widget.TextView;
import androidx.test.espresso.Espresso;
import androidx.test.rule.ActivityTestRule;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.testhelpers.action.ViewActions;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.addon.AddonsActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickRecyclerViewItem;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.rotateDevice;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.selectListItemPressBackAndCheckActionbarTitle;
/**
* Note: we use MoviesActivity here instead of AddonsActivity. The reason is that we use @Rule
* to start the activity which is done prior to executing @Before. This results in a deadlock
* situation.
*
* Normal startup procedure would be as follows:
*
* 1. Start MockTCPServer {@link AbstractTestClass#setupMockTCPServer()}
* 2. Start activity {mActivityRule}
* 3. Espresso waits for activity to become idle before calling {@link AbstractTestClass#setUp()}
* 4. Add AddonsHandler {@link AbstractTestClass#setUp()}
*
* At step 2 the AddonsActivity displays an animated progress indicator while it waits for the
* MockTCPServer to send the list of addons.
* This is never send as the {@link org.xbmc.kore.testutils.tcpserver.handlers.AddonsHandler} is
* added in {@link super#setUp()} which is never started by Espresso as it waits for
* {@link org.xbmc.kore.ui.sections.addon.AddonsActivity} to become idle.
*/
public class AddonsActivityTests extends AbstractTestClass<AddonsActivity> {
@Rule
public ActivityTestRule<AddonsActivity> mActivityRule = new ActivityTestRule<>(AddonsActivity.class);
@Override
protected ActivityTestRule<AddonsActivity> getActivityTestRule() {
return mActivityRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
@Before
public void setUp() throws Throwable {
super.setUp();
onView(isRoot()).perform(ViewActions.waitForView(R.id.list, v -> v.isShown(),10000));
}
/**
* Test if action bar title initially displays Addons
*/
@Test
public void setActionBarTitleMain() {
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(R.string.addons)));
}
/**
* Test if action bar title is correctly set after selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitle() {
EspressoTestUtils.selectListItemAndCheckActionbarTitle("Dumpert", R.id.list,
"Dumpert");
}
/**
* Test if action bar title is correctly restored after a configuration change
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: action bar title should show list item title
*/
@Test
public void restoreActionBarTitleOnConfigurationStateChanged() {
EspressoTestUtils.selectListItemRotateDeviceAndCheckActionbarTitle("Dumpert", R.id.list,
"Dumpert",
getActivity());
}
/**
* Test if action bar title is correctly restored after returning from a movie selection
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: action bar title should show main title
*/
@Test
public void restoreActionBarTitleOnReturningFromMovie() {
selectListItemPressBackAndCheckActionbarTitle(0, R.id.list,
getActivity().getString(R.string.addons));
}
/**
* Test if the initial state shows the hamburger icon
*/
@Test
public void showHamburgerInInitialState() {
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: navigation icon should be an arrow
*/
@Test
public void showArrowWhenSelectingListItem() {
clickRecyclerViewItem(0, R.id.list);
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: navigation icon should be a hamburger
*/
@Test
public void showHamburgerWhenSelectingListItemAndReturn() {
clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an arrow when selecting a list item
* and rotating the device
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: navigation icon should be an arrow
*/
@Test
public void restoreArrowOnConfigurationChange() {
clickRecyclerViewItem(0, R.id.list);
rotateDevice(getActivity());
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an hamburger when selecting a list item
* and rotating the device and returning to the list
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Press back
* 4. Result: navigation icon should be a hamburger
*/
@Test
public void restoreHamburgerOnConfigurationChangeOnReturn() {
clickRecyclerViewItem(0, R.id.list);
rotateDevice(getActivity());
Espresso.pressBack();
assertTrue(EspressoTestUtils.getActivity() instanceof AddonsActivity);
assertFalse(((AddonsActivity) EspressoTestUtils.getActivity()).getDrawerIndicatorIsArrow());
}
}

View file

@ -0,0 +1,186 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.movies;
import android.content.Context;
import android.widget.TextView;
import androidx.test.espresso.Espresso;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.video.MoviesActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickRecyclerViewItem;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.rotateDevice;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.selectListItemPressBackAndCheckActionbarTitle;
public class MoviesActivityTests extends AbstractTestClass<MoviesActivity> {
@Rule
public ActivityTestRule<MoviesActivity> mActivityRule = new ActivityTestRule<>(
MoviesActivity.class);
@Override
protected ActivityTestRule<MoviesActivity> getActivityTestRule() {
return mActivityRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
/**
* Test if action bar title initially displays Movies
*/
@Test
public void setActionBarTitleMain() {
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(R.string.movies)));
}
/**
* Test if action bar title is correctly set after selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitle() {
EspressoTestUtils.selectListItemAndCheckActionbarTitle("#Rookie93 Marc Marquez: Beyond the Smile", R.id.list,
"#Rookie93 Marc Marquez: Beyond the Smile");
}
/**
* Test if action bar title is correctly restored after a configuration change
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: action bar title should show list item title
*/
@Test
public void restoreActionBarTitleOnConfigurationStateChanged() {
EspressoTestUtils.selectListItemRotateDeviceAndCheckActionbarTitle("#Rookie93 Marc Marquez: Beyond the Smile", R.id.list,
"#Rookie93 Marc Marquez: Beyond the Smile",
getActivity());
}
/**
* Test if action bar title is correctly restored after returning from a movie selection
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: action bar title should show main title
*/
@Test
public void restoreActionBarTitleOnReturningFromMovie() {
selectListItemPressBackAndCheckActionbarTitle(0, R.id.list,
getActivity().getString(R.string.movies));
}
/**
* Test if the initial state shows the hamburger icon
*/
@Test
public void showHamburgerInInitialState() {
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: navigation icon should be an arrow
*/
@Test
public void showArrowWhenSelectingListItem() {
clickRecyclerViewItem(0, R.id.list);
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: navigation icon should be a hamburger
*/
@Test
public void showHamburgerWhenSelectingListItemAndReturn() {
clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an arrow when selecting a list item
* and rotating the device
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: navigation icon should be an arrow
*/
@Test
public void restoreArrowOnConfigurationChange() {
clickRecyclerViewItem(0, R.id.list);
rotateDevice(getActivity());
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an hamburger when selecting a list item
* and rotating the device and returning to the list
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Press back
* 4. Result: navigation icon should be a hamburger
*/
@Test
public void restoreHamburgerOnConfigurationChangeOnReturn() {
clickRecyclerViewItem(0, R.id.list);
rotateDevice(getActivity());
Espresso.pressBack();
assertTrue(EspressoTestUtils.getActivity() instanceof MoviesActivity);
assertFalse(((MoviesActivity) EspressoTestUtils.getActivity()).getDrawerIndicatorIsArrow());
}
}

View file

@ -0,0 +1,219 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.movies;
import android.content.Context;
import androidx.test.espresso.Espresso;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.video.MoviesActivity;
@RunWith(AndroidJUnit4.class)
public class RestoreSearchQueryListFragmentTest extends AbstractTestClass<MoviesActivity> {
private final String SEARCH_QUERY = "Room";
private final int SEARCH_QUERY_LIST_SIZE = 2;
private final int COMPLETE_LIST_SIZE = 300;
@Rule
public ActivityTestRule<MoviesActivity> mActivityRule = new ActivityTestRule<>(
MoviesActivity.class);
@Override
protected ActivityTestRule<MoviesActivity> getActivityTestRule() {
return mActivityRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
/**
* Simple test that checks if search query results in expected item(s)
*/
@Test
public void simpleSearchTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.checkTextInSearchQuery(SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(SEARCH_QUERY, SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Simple test that checks if search query is restored after device rotate
*/
@Test
public void simpleRotateTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.rotateDevice(mActivityRule.getActivity());
EspressoTestUtils.checkTextInSearchQuery(SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(SEARCH_QUERY, SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Test if search query is restored when user returns to list fragment from
* detail fragment
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Click on list item
* 3. Press back
* 4. Result: search query entered at 1. should be restored in search field
*/
@Test
public void searchClickBackTest() {
EspressoTestUtils.clearSearchQuery(mActivityRule.getActivity());
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
EspressoTestUtils.checkTextInSearchQuery(SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(SEARCH_QUERY, SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Test if search query is restored when user returns to list fragment from
* detail fragment when device is rotated while on detail fragment
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Click on list item
* 3. Rotate device
* 4. Press back
* 5. Result: search query entered at 1. should be restored in search field
*/
@Test
public void searchClickRotateBackTest() {
EspressoTestUtils.clearSearchQuery(mActivityRule.getActivity());
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(0, R.id.list);
EspressoTestUtils.rotateDevice(mActivityRule.getActivity());
Espresso.pressBack();
EspressoTestUtils.checkTextInSearchQuery(SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(SEARCH_QUERY, SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Test if saved search query is cleared when user clears the
* search query view
*
* UI interaction flow tested
* 1. Enter search query
* 2. Click on list item
* 3. Return to list
* 4. Clear search query
* 5. Click on list item
* 6. Return to list
* 7. Result: search query should be empty and collapsed
*/
@Test
public void searchClickBackClearSearchClickBackTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
EspressoTestUtils.clearSearchQuery(mActivityRule.getActivity());
EspressoTestUtils.clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
EspressoTestUtils.checkSearchMenuCollapsed();
}
/**
* Test if after restoring search query the search query is cleared
* when user presses back again.
*
* UI interaction flow tested
* 1. Enter search query
* 2. Click on list item
* 3. Return to list
* 4. Press back
* 7. Result: search query should be cleared, collapsed, and list should show everything
*/
@Test
public void searchClickBackBackTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
Espresso.pressBack();
EspressoTestUtils.checkSearchMenuCollapsed();
EspressoTestUtils.checkListMatchesSearchQuery("", COMPLETE_LIST_SIZE, R.id.list);
}
/**
* Test if pressing back clears a previous search
*
* UI interaction flow tested
* 1. Enter search query
* 2. Press back
* 3. Result: search query should be cleared, collapsed, and list should show everything
*/
@Test
public void searchBackTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
Espresso.pressBack();
EspressoTestUtils.checkSearchMenuCollapsed();
EspressoTestUtils.checkListMatchesSearchQuery("", COMPLETE_LIST_SIZE, R.id.list);
}
/**
* Test if after restoring the search query pressing home button up clears a previous search
*
* UI interaction flow tested
* 1. Enter search query
* 2. Click on list item
* 3. Press back
* 4. Press home button
* 5. Result: search query should be cleared, collapsed, and list should show everything
*/
@Test
public void searchClickBackUpTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
EspressoTestUtils.clickToolbarCollapseButton();
EspressoTestUtils.checkSearchMenuCollapsed();
EspressoTestUtils.checkListMatchesSearchQuery("", COMPLETE_LIST_SIZE, R.id.list);
}
/**
* Test if pressing home button up clears a previous search
*
* UI interaction flow tested
* 1. Enter search query
* 2. Press home button
* 3. Result: search query should be cleared, collapsed, and list should show everything
*/
@Test
public void searchUpTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), SEARCH_QUERY);
EspressoTestUtils.clickToolbarCollapseButton();
EspressoTestUtils.checkSearchMenuCollapsed();
EspressoTestUtils.checkListMatchesSearchQuery("", COMPLETE_LIST_SIZE, R.id.list);
}
}

View file

@ -0,0 +1,366 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.music;
import android.content.Context;
import android.os.SystemClock;
import android.widget.TextView;
import androidx.test.espresso.Espresso;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.audio.MusicActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickAlbumsTab;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickGenresTab;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickMusicVideosTab;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.rotateDevice;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.selectListItemAndCheckActionbarTitle;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.selectListItemPressBackAndCheckActionbarTitle;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.selectListItemRotateDeviceAndCheckActionbarTitle;
public class MusicActivityTests extends AbstractTestClass<MusicActivity> {
@Rule
public ActivityTestRule<MusicActivity> musicActivityActivityTestRule =
new ActivityTestRule<>(MusicActivity.class);
@Override
protected ActivityTestRule<MusicActivity> getActivityTestRule() {
return musicActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
/**
* Test if action bar title initially displays Music
*/
@Test
public void setActionBarTitleMain() {
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(R.string.music)));
}
/**
* Test if action bar title is correctly set after selecting an artist
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitleArtist() {
selectListItemAndCheckActionbarTitle(ArtistTestData.title, R.id.list, ArtistTestData.title);
}
/**
* Test if action bar title is correctly set after selecting an album
*
* UI interaction flow tested:
* 1. Click on albums tab
* 2. Click on list item
* 3. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitleAlbum() {
clickAlbumsTab();
selectListItemAndCheckActionbarTitle(AlbumTestData.title, R.id.list, AlbumTestData.title);
}
/**
* Test if action bar title is correctly set after selecting a genre
*
* UI interaction flow tested:
* 1. Click on genres tab
* 2. Click on list item
* 3. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitleGenre() {
clickGenresTab();
selectListItemAndCheckActionbarTitle(GenreTestData.title, R.id.list, GenreTestData.title);
}
/**
* Test if action bar title is correctly set after selecting a video
*
* UI interaction flow tested:
* 1. Click on videos tab
* 2. Click on list item
* 3. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitleVideo() {
clickMusicVideosTab();
selectListItemAndCheckActionbarTitle(MusicVideoTestData.title, R.id.list, MusicVideoTestData.title);
}
/**
* Test if action bar title is correctly restored after a configuration change when artist
* is selected
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: action bar title should show list item title
*/
@Test
public void restoreActionBarTitleArtistOnConfigurationStateChanged() {
SystemClock.sleep(10000);
selectListItemRotateDeviceAndCheckActionbarTitle(ArtistTestData.title, R.id.list,
ArtistTestData.title, getActivity());
}
/**
* Test if action bar title is correctly restored after a configuration change when album
* is selected
*
* UI interaction flow tested:
* 1. Select albums tab
* 2. Click on list item
* 3. Rotate device
* 4. Result: action bar title should show list item title
*/
@Test
public void restoreActionBarTitleAlbumOnConfigurationStateChanged() {
clickAlbumsTab();
selectListItemRotateDeviceAndCheckActionbarTitle(AlbumTestData.title, R.id.list,
AlbumTestData.title,
getActivity());
}
/**
* Test if action bar title is correctly restored after a configuration change when genre
* is selected
*
* UI interaction flow tested:
* 1. Select genres tab
* 2. Click on list item
* 3. Rotate device
* 4. Result: action bar title should show list item title
*/
@Test
public void restoreActionBarTitleGenreOnConfigurationStateChanged() {
clickGenresTab();
selectListItemRotateDeviceAndCheckActionbarTitle(GenreTestData.title, R.id.list,
GenreTestData.title, getActivity());
}
/**
* Test if action bar title is correctly restored after a configuration change when music video
* is selected
*
* UI interaction flow tested:
* 1. Select music videos tab
* 2. Click on list item
* 3. Rotate device
* 4. Result: action bar title should show list item title
*/
@Test
public void restoreActionBarTitleMusicVideoOnConfigurationStateChanged() {
clickMusicVideosTab();
selectListItemRotateDeviceAndCheckActionbarTitle(MusicVideoTestData.title, R.id.list,
MusicVideoTestData.title,
getActivity());
}
/**
* Test if action bar title is correctly restored after returning from artist selection
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: action bar title should show main title
*/
@Test
public void restoreActionBarTitleOnReturningFromArtist() {
selectListItemPressBackAndCheckActionbarTitle(ArtistTestData.title, R.id.list,
getActivity().getString(R.string.music));
}
/**
* Test if action bar title is correctly restored after returning from an album under
* artist
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Select albums tab
* 3. Press back
* 4. Result: action bar title should show artist title
*/
@Test
public void restoreActionBarTitleOnArtistOnReturningFromAlbum() {
EspressoTestUtils.clickRecyclerViewItem(ArtistTestData.title, R.id.list);
clickAlbumsTab();
selectListItemPressBackAndCheckActionbarTitle(ArtistTestData.album, R.id.list, ArtistTestData.title);
}
/**
* Test if action bar title is correctly restored after returning from music video selection
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: action bar title should show main title
*/
@Test
public void restoreActionBarTitleOnReturningFromMusicVideo() {
clickMusicVideosTab();
selectListItemPressBackAndCheckActionbarTitle(MusicVideoTestData.title, R.id.list,
getActivity().getString(R.string.music));
}
/**
* Test if action bar title is correctly restored after returning from genre selection
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: action bar title should show main title
*/
@Test
public void restoreActionBarTitleOnReturningFromGenre() {
clickGenresTab();
selectListItemPressBackAndCheckActionbarTitle(GenreTestData.title, R.id.list,
getActivity().getString(R.string.music));
}
/**
* Test if action bar title is correctly restored after returning from album selection
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: action bar title should show main title
*/
@Test
public void restoreActionBarTitleOnReturningFromAlbum() {
clickAlbumsTab();
selectListItemPressBackAndCheckActionbarTitle(AlbumTestData.title, R.id.list,
getActivity().getString(R.string.music));
}
/**
* Test if the initial state shows the hamburger icon
*/
@Test
public void showHamburgerInInitialState() {
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: navigation icon should be an arrow
*/
@Test
public void showArrowWhenSelectingListItem() {
EspressoTestUtils.clickRecyclerViewItem(ArtistTestData.title, R.id.list);
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: navigation icon should be a hamburger
*/
@Test
public void showHamburgerWhenSelectingListItemAndReturn() {
EspressoTestUtils.clickRecyclerViewItem(ArtistTestData.title, R.id.list);
Espresso.pressBack();
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an arrow when selecting a list item
* and rotating the device
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: navigation icon should be an arrow
*/
@Test
public void restoreArrowOnConfigurationChange() {
EspressoTestUtils.clickRecyclerViewItem(ArtistTestData.title, R.id.list);
rotateDevice(getActivity());
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an hamburger when selecting a list item
* and rotating the device and returning to the list
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Press back
* 4. Result: navigation icon should be a hamburger
*/
@Test
public void restoreHamburgerOnConfigurationChangeOnReturn() {
EspressoTestUtils.clickRecyclerViewItem(ArtistTestData.title, R.id.list);
rotateDevice(getActivity());
Espresso.pressBack();
assertTrue(EspressoTestUtils.getActivity() instanceof MusicActivity);
assertFalse(((MusicActivity) EspressoTestUtils.getActivity()).getDrawerIndicatorIsArrow());
}
private static class ArtistTestData {
static String title = "ABC Orch Conducted by Herschel Burke Gilbert";
static String album = "Songs Of The West";
}
private static class AlbumTestData {
static String title = "1958 - The Fabulous Johnny Cash";
}
private static class GenreTestData {
static String title = "Ambient";
}
private static class MusicVideoTestData {
static String title = "(You Drive Me) Crazy";
}
}

View file

@ -0,0 +1,315 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.music;
import android.app.Activity;
import android.content.Context;
import androidx.test.espresso.Espresso;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.testhelpers.LoaderIdlingResource;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.audio.MusicActivity;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickAlbumsTab;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickArtistsTab;
@RunWith(AndroidJUnit4.class)
public class RestoreSearchQueryViewPagerTest extends AbstractTestClass<MusicActivity> {
private final String ARTIST_SEARCH_QUERY = "Ben";
private final int ARTIST_SEARCH_QUERY_LIST_SIZE = 2;
private final String ARTIST_MATCHING_SEARCH_QUERY = "Ben E. King";
private final String ALBUMS_SEARCH_QUERY = "tes";
private final int ALBUM_SEARCH_QUERY_LIST_SIZE = 3;
private final int ARTIST_COMPLETE_LIST_SIZE = 229;
private final int ALBUM_COMPLETE_LIST_SIZE = 235;
private LoaderIdlingResource loaderIdlingResource;
@Rule
public ActivityTestRule<MusicActivity> mActivityRule = new ActivityTestRule<>(
MusicActivity.class);
@Override
protected ActivityTestRule<MusicActivity> getActivityTestRule() {
return mActivityRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
/**
* Simple test that checks if search query results in expected item(s)
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Result: search query entered at 1. should show in search field and list should match search query
*/
@Test
public void simpleSearchTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Simple test that checks if search query is restored after device rotate
* UI interaction flow tested:
* 1. Enter search query
* 2. Rotate device
* 3. Result: search query entered at 1. should show in search field and list should match search query
*/
@Test
public void simpleRotateTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), ARTIST_SEARCH_QUERY);
EspressoTestUtils.rotateDevice(mActivityRule.getActivity());
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Test if search query is restored when user returns to list fragment from
* detail fragment
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Click on list item
* 3. Press back
* 4. Result: search query entered at 1. should be restored in search field
*/
@Test
public void searchClickBackTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), ARTIST_SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(ARTIST_MATCHING_SEARCH_QUERY, R.id.list);
Espresso.pressBack();
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Test if search query is restored when user returns to list fragment from
* detail fragment when device is rotated while on detail fragment
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Click on list item
* 3. Rotate device
* 4. Press back
* 5. Result: search query entered at 1. should be restored in search field
*/
@Test
public void searchClickRotateBackTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), ARTIST_SEARCH_QUERY);
EspressoTestUtils.clickRecyclerViewItem(ARTIST_MATCHING_SEARCH_QUERY, R.id.list);
EspressoTestUtils.rotateDevice(mActivityRule.getActivity());
Espresso.pressBack();
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Test if search query is cleared when switching to
* different tab in the TabAdapter
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Switch to Albums tab
* 3. Result: search query should be cleared
*/
@Test
public void searchSwitchTabTest() {
Activity activity = mActivityRule.getActivity();
EspressoTestUtils.enterSearchQuery(activity, ARTIST_SEARCH_QUERY);
clickAlbumsTab();
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
EspressoTestUtils.checkTextInSearchQuery("");
}
/**
* Tests if search query is still cleared when
* device is rotated after switching to a different tab
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Switch to Albums tab
* 3. Rotate device
* 4. Open search menu item
* 5. Result: search query should be cleared
*/
@Test
public void searchSwitchTabRotateTest() {
Activity activity = mActivityRule.getActivity();
EspressoTestUtils.enterSearchQuery(activity, ARTIST_SEARCH_QUERY);
clickAlbumsTab();
EspressoTestUtils.rotateDevice(activity);
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
Espresso.closeSoftKeyboard();
EspressoTestUtils.checkTextInSearchQuery("");
EspressoTestUtils.checkListMatchesSearchQuery("", ALBUM_COMPLETE_LIST_SIZE, R.id.list);
}
/**
* Tests if search query is restored when returning
* to the original tab
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Switch to Albums tab
* 3. Switch to Artists tab
* 4. Result: search query entered at 1. should show in search field and list should match search query
*/
@Test
public void searchSwitchTabReturnTest() {
EspressoTestUtils.enterSearchQuery(mActivityRule.getActivity(), ARTIST_SEARCH_QUERY);
clickAlbumsTab();
clickArtistsTab();
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Tests if search query is restored when returning
* to the original tab after switching to a different
* tab and rotating the device
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Switch to Albums tab
* 3. Rotate device
* 4. Switch to Artists tab
* 5. Result: search query entered at 1. should show in search field and list should match search query
*/
@Test
public void searchSwitchTabRotateReturnTest() {
Activity activity = mActivityRule.getActivity();
EspressoTestUtils.enterSearchQuery(activity, ARTIST_SEARCH_QUERY);
clickAlbumsTab();
EspressoTestUtils.rotateDevice(activity);
clickArtistsTab();
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
/**
* Tests if search query is still cleared when user clears a previous
* search query and switches to a different tab and returns to the
* original tab
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Clear search query
* 3. Switch to Albums tab
* 4. Switch to Artists tab
* 5. Click search menu item
* 6. Result: search query should be cleared and list should contain all items
*/
@Test
public void searchClearSwitchTabSwitchBack() {
Activity activity = mActivityRule.getActivity();
EspressoTestUtils.enterSearchQuery(activity, ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.clearSearchQuery(activity);
clickAlbumsTab();
clickArtistsTab();
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
EspressoTestUtils.checkTextInSearchQuery("");
EspressoTestUtils.checkListMatchesSearchQuery("", ARTIST_COMPLETE_LIST_SIZE, R.id.list);
}
/**
* Same test as {@link #searchClearSwitchTabSwitchBack()} but this time clearing performed using X button
*
* UI interaction flow tested:
* 1. Enter search query
* 2. Clear search query
* 3. Switch to Albums tab
* 4. Switch to Artists tab
* 5. Click search menu item using X button
* 6. Result: search query should be cleared and list should contain all items
*/
@Test
public void searchSwitchTabSwitchBackClearUsingXButtonSwitchTabSwitchBack() {
Activity activity = mActivityRule.getActivity();
EspressoTestUtils.enterSearchQuery(activity, ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
clickAlbumsTab();
clickArtistsTab();
EspressoTestUtils.clearSearchQueryXButton(activity);
clickAlbumsTab();
clickArtistsTab();
EspressoTestUtils.checkSearchMenuCollapsed();
EspressoTestUtils.clickMenuItem(activity, activity.getString(R.string.action_search), R.id.action_search);
EspressoTestUtils.checkTextInSearchQuery("");
EspressoTestUtils.checkListMatchesSearchQuery("", ARTIST_COMPLETE_LIST_SIZE, R.id.list);
}
/**
* Tests if search queries for separate tabs are restored correctly
*
* UI interaction flow tested:
* 1. Enter search query artists tab
* 2. Enter search query albums tab
* 3. Switch to Artists tab
* 4. Result: search query entered at 1. should show in search field and list should match search query
* 5. Switch to Albums tab
* 6. Result: search query entered at 2. should show in search field and list should match search query
*/
@Test
public void searchArtistsSearchAlbumsSwitchArtists() {
Activity activity = mActivityRule.getActivity();
EspressoTestUtils.enterSearchQuery(activity, ARTIST_SEARCH_QUERY);
clickAlbumsTab();
EspressoTestUtils.enterSearchQuery(activity, ALBUMS_SEARCH_QUERY);
clickArtistsTab();
EspressoTestUtils.checkTextInSearchQuery(ARTIST_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ARTIST_SEARCH_QUERY, ARTIST_SEARCH_QUERY_LIST_SIZE, R.id.list);
clickAlbumsTab();
EspressoTestUtils.checkTextInSearchQuery(ALBUMS_SEARCH_QUERY);
EspressoTestUtils.checkListMatchesSearchQuery(ALBUMS_SEARCH_QUERY, ALBUM_SEARCH_QUERY_LIST_SIZE, R.id.list);
}
}

View file

@ -0,0 +1,666 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.music;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.SystemClock;
import android.view.View;
import android.widget.SeekBar;
import android.widget.TextView;
import androidx.preference.PreferenceManager;
import androidx.test.rule.ActivityTestRule;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.Settings;
import org.xbmc.kore.testhelpers.Utils;
import org.xbmc.kore.testhelpers.action.ViewActions;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.testutils.tcpserver.handlers.PlayerHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Application;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Player;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Playlist;
import org.xbmc.kore.ui.sections.audio.MusicActivity;
import org.xbmc.kore.ui.widgets.HighlightButton;
import org.xbmc.kore.ui.widgets.NowPlayingPanel;
import org.xbmc.kore.ui.widgets.RepeatModeButton;
import java.util.concurrent.TimeoutException;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.pressBack;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickAdapterViewItem;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.rotateDevice;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.waitForPanelState;
import static org.xbmc.kore.testhelpers.Matchers.withHighlightState;
import static org.xbmc.kore.testhelpers.Matchers.withProgress;
import static org.xbmc.kore.testutils.TestUtils.createMusicItem;
import static org.xbmc.kore.testutils.TestUtils.createMusicVideoItem;
import static org.xbmc.kore.testutils.TestUtils.createVideoItem;
public class SlideUpPanelTests extends AbstractTestClass<MusicActivity> {
@Rule
public ActivityTestRule<MusicActivity> musicActivityActivityTestRule =
new ActivityTestRule<>(MusicActivity.class);
@Override
protected ActivityTestRule<MusicActivity> getActivityTestRule() {
return musicActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
@Override
public void setUp() throws Throwable {
super.setUp();
getPlaylistHandler().reset();
getPlaylistHandler().addItemToPlaylist(Playlist.playlistID.AUDIO, createMusicItem(0, 0), true);
getPlaylistHandler().addItemToPlaylist(Playlist.playlistID.VIDEO, createVideoItem(0, 1), false);
getPlaylistHandler().addItemToPlaylist(Playlist.playlistID.VIDEO, createMusicVideoItem(0, 2), false);
getPlayerHandler().reset();
getPlayerHandler().setPlaylists(getPlaylistHandler().getPlaylists());
getPlayerHandler().startPlay(Playlist.playlistID.AUDIO, 0);
waitForPanelState(BottomSheetBehavior.STATE_COLLAPSED);
}
/**
* Test if panel title is correctly set
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Result: panel title should show current playing media item
*/
@Test
public void panelTitleTest() {
Player.GetItem item = getPlayerHandler().getMediaItem();
onView(withId(R.id.title)).check(matches(withText(item.getTitle())));
}
/**
* Test if panel buttons are correctly set for music items
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Result: panel should show next, play, and previous buttons
*/
@Test
public void panelButtonsMusicTest() {
onView(withId(R.id.next)).check(matches(isDisplayed()));
onView(withId(R.id.previous)).check(matches(isDisplayed()));
onView(withId(R.id.play)).check(matches(isDisplayed()));
}
/**
* Test if panel buttons are correctly set for movie items
*
* UI interaction flow tested:
* 1. Start playing a movie item
* 2. Result: panel should show play button
*/
@Test
public void panelButtonsMoviesTest() {
getPlayerHandler().startPlay(Playlist.playlistID.VIDEO, 0);
Player.GetItem item = getPlayerHandler().getMediaItem();
final String title = item.getTitle();
onView(isRoot()).perform(ViewActions.waitForView(
R.id.title, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return title.contentEquals(((TextView) v).getText());
}
}, 10000));
onView(withId(R.id.next)).check(matches(not(isDisplayed())));
onView(withId(R.id.previous)).check(matches(not(isDisplayed())));
onView(withId(R.id.play)).check(matches(isDisplayed()));
}
/**
* Test if panel buttons are correctly set for music video items
*
* UI interaction flow tested:
* 1. Start playing a music video item
* 2. Result: panel should show next, play, and previous buttons
*/
@Test
public void panelButtonsMusicVideoTest() {
getPlayerHandler().startPlay(Playlist.playlistID.VIDEO, 1);
Player.GetItem item = getPlayerHandler().getMediaItem();
final String title = item.getTitle();
onView(isRoot()).perform(ViewActions.waitForView(
R.id.title, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return title.contentEquals(((TextView) v).getText());
}
}, 10000));
onView(withId(R.id.next)).check(matches(isDisplayed()));
onView(withId(R.id.previous)).check(matches(isDisplayed()));
onView(withId(R.id.play)).check(matches(isDisplayed()));
}
/**
* Test if shuffle button state is correctly set
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Click on shuffle button
* 4. Result: shuffle button should be highlighted
*/
@Test
public void panelButtonsShuffleTest() {
expandPanel();
onView(withId(R.id.shuffle)).perform(click());
onView(isRoot()).perform(ViewActions.waitForView(R.id.shuffle, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((HighlightButton) v).isHighlighted();
}
}, 10000));
}
/**
* Test if repeat button state is correctly set
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Click on repeat button
* 4. Result: repeat button should be highlighted and show single item repeat mode
* 5. Click on repeat button
* 6. Result: repeat button should be highlighted and show repeat playlist mode
* 7. Click on repeat button
* 8. Result: repeat button should not be highlighted
*/
@Test
public void panelButtonsRepeatModes() {
expandPanel();
//Initial state should be OFF
onView(isRoot()).perform(ViewActions.waitForView(R.id.repeat, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((RepeatModeButton) v).getMode() == RepeatModeButton.MODE.OFF;
}
}, 10000));
// Test if repeat mode is set to ONE after first click
onView(withId(R.id.repeat)).perform(click());
onView(isRoot()).perform(ViewActions.waitForView(R.id.repeat, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((RepeatModeButton) v).getMode() == RepeatModeButton.MODE.ONE;
}
}, 10000));
// Test if repeat mode is set to ALL after second click
onView(withId(R.id.repeat)).perform(click());
onView(isRoot()).perform(ViewActions.waitForView(R.id.repeat, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((RepeatModeButton) v).getMode() == RepeatModeButton.MODE.ALL;
}
}, 10000));
// Test if repeat mode is set to OFF after third click
onView(withId(R.id.repeat)).perform(click());
onView(isRoot()).perform(ViewActions.waitForView(R.id.repeat, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((RepeatModeButton) v).getMode() == RepeatModeButton.MODE.OFF;
}
}, 10000));
}
/**
* Test if panel collapsed state is restored on configuration changes
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Rotate device
* 3. Result: panel state should be collapsed
*/
@Test
public void keepCollapsedOnRotate() {
rotateDevice(getActivity());
waitForPanelState(BottomSheetBehavior.STATE_COLLAPSED;
}
/**
* Test if panel expanded state is restored on configuration changes
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Rotate device
* 4. Result: panel state should be expanded
*/
@Test
public void keepExpandedOnRotate() {
expandPanel();
rotateDevice(getActivity());
waitForPanelState(BottomSheetBehavior.STATE_EXPANDED);
}
/**
* Test if repeat button state is restored on configuration changes
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Click on repeat button
* 4. Rotate device
* 5. Result: repeat button state should be restored to state in step 2
*/
@Test
public void restoreRepeatButtonStateOnRotate() {
expandPanel();
onView(withId(R.id.repeat)).perform(click());
rotateDevice(getActivity());
onView(isRoot()).perform(ViewActions.waitForView(R.id.repeat, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((RepeatModeButton) v).getMode() == RepeatModeButton.MODE.ONE;
}
}, 10000));
}
/**
* Test if shuffle button state is correctly set
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Click on shuffle button
* 4. Result: shuffle button state should be set to shuffle
*/
@Test
public void setShuffleButtonState() {
expandPanel();
onView(withId(R.id.shuffle)).perform(click()); //Set state to shuffled
onView(withId(R.id.shuffle)).check(matches(withHighlightState(true)));
}
/**
* Test if shuffle button state is restored on configuration changes
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Click on shuffle button
* 4. Rotate device
* 5. Result: shuffle button state should be restored to state in step 2
*/
@Test
public void restoreShuffleButtonStateOnRotate() {
expandPanel();
onView(withId(R.id.shuffle)).perform(click()); //Set state to shuffled
rotateDevice(getActivityTestRule().getActivity());
//Using waitForView as we need to wait for the rotate to finish
onView(isRoot()).perform(ViewActions.waitForView(R.id.shuffle, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((HighlightButton) v).isHighlighted();
}
}, 10000));
}
/**
* Test if volume is correctly set at start
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Set volume at server
* 3. Expand panel
* 4. Result: Volume indicator should show the same volume level as set at the server
*/
@Test
public void setVolume() {
final int volume = 16;
getApplicationHandler().setVolume(volume, true);
assertTrue(getApplicationHandler().getVolume() == volume);
expandPanel();
onView(withId(R.id.vli_seek_bar)).check(matches(withProgress(volume)));
onView(withId(R.id.vli_volume_text)).check(matches(withText(String.valueOf(volume))));
}
/**
* Test if changing volume through the volume slider, updates the volume indicator correctly
* and sends the volume change to the server
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Set volume using slider
* 4. Result: Volume indicator should show volume level and server should be set to new volume level
*/
@Test
public void changeVolume() throws TimeoutException {
final int volume = 16;
expandPanel();
onView(withId(R.id.vli_seek_bar)).perform(ViewActions.slideSeekBar(volume));
onView(withId(R.id.vli_seek_bar)).check(matches(withProgress(volume)));
onView(withId(R.id.vli_volume_text)).check(matches(withText(String.valueOf(volume))));
getConnectionHandlerManager().waitForMethodHandled(Application.SetVolume.METHOD_NAME, 10000);
assertTrue("applicationHandler volume: "+ getApplicationHandler().getVolume()
+ " != " + volume, getApplicationHandler().getVolume() == volume);
}
/**
* Test if changing volume through the volume slider, updates the volume indicator correctly
* and sends the volume change to the server
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Set volume using slider
* 4. Result: Volume indicator should show volume level and server should be set to new volume level
*/
@Test
public void restoreVolumeIndicatorOnRotate() throws TimeoutException {
final int volume = 16;
expandPanel();
onView(withId(R.id.vli_seek_bar)).perform(ViewActions.slideSeekBar(volume));
rotateDevice(getActivity());
assertTrue("applicationHandler volume: "+ getApplicationHandler().getVolume()
+ " != " + volume, getApplicationHandler().getVolume() == volume);
onView(isRoot()).perform(ViewActions.waitForView(R.id.vli_seek_bar, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((SeekBar) v).getProgress() == volume;
}
}, 10000));
onView(withId(R.id.vli_volume_text)).check(matches(withText(String.valueOf(volume))));
}
/**
* Test if setting progression correctly updates the media progress indicator
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Pause playback
* 3. Expand panel
* 4. Set progression
* 5. Result: Media progression indicator should be correctly updated and progression change
* should be sent to the server.
*/
@Test
public void setProgression() {
final int progress = 16;
final String progressText = "0:16";
expandPanel();
onView(withId(R.id.play)).perform(click()); //Pause playback
onView(withId(R.id.mpi_seek_bar)).perform(ViewActions.slideSeekBar(progress));
onView(withId(R.id.mpi_progress)).check(matches(withText(progressText)));
assertTrue(getPlayerHandler().getTimeElapsed() == progress);
}
/**
* Test if progression is correctly restored after device configuration change
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Pause playback
* 3. Expand panel
* 4. Set progression
* 5. Rotate device
* 6. Result: Progression should be correctly same as before rotating the device.
*/
@Test
public void restoreProgressOnRotate() {
final int progress = 16;
final String progressText = "0:16";
expandPanel();
onView(withId(R.id.play)).perform(click()); //Pause playback
onView(withId(R.id.mpi_seek_bar)).perform(ViewActions.slideSeekBar(progress));
rotateDevice(getActivity());
assertEquals(getPlayerHandler().getTimeElapsed(), progress);
onView(withId(R.id.mpi_progress)).check(matches(withProgress(progressText)));
onView(withId(R.id.mpi_seek_bar)).check(matches(withProgress(progress)));
}
/**
* Kodi resumes playback when progression changes.
* Test if changing progression when player is paused caused
* progression to start updating again
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Pause playback
* 4. Set progression
* 5. Start playback at server (that's what Kodi does)
* 6. Result: Playback should start at paused position
*/
@Test
public void pauseSetProgressionPlay() {
expandPanel();
onView(withId(R.id.play)).perform(click()); //Pause playback
onView(withId(R.id.mpi_seek_bar)).perform(ViewActions.slideSeekBar(16));
getPlayerHandler().startPlay();
SeekBar seekBar = (SeekBar) getActivity().findViewById(R.id.mpi_seek_bar);
final int progress = seekBar.getProgress();
onView(isRoot()).perform(ViewActions.waitForView(
R.id.mpi_seek_bar, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((SeekBar) v).getProgress() > progress;
}
}, 10000));
}
/**
* Test if panel's progressionbar progresses when playing media
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Result: Progression should be progressing
*/
@Test
public void progressionUpdaterStartedAfterPlay() {
expandPanel();
SeekBar seekBar = (SeekBar) getActivity().findViewById(R.id.mpi_seek_bar);
final int progress = seekBar.getProgress();
onView(isRoot()).perform(ViewActions.waitForView(
R.id.mpi_seek_bar, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((SeekBar) v).getProgress() > progress;
}
}, 10000));
}
/**
* Test if panel's progression is maintained when starting a new activity
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Expand panel
* 3. Set progression
* 4. Switch to movies (new activity)
* 5. Result: Progression should continue from step 3
*/
@Test
public void continueProgressionAfterSwitchingActivity() throws Throwable {
final int progress = 24;
expandPanel();
onView(withId(R.id.mpi_seek_bar)).perform(ViewActions.slideSeekBar(progress));
Utils.openDrawer(getActivityTestRule());
clickAdapterViewItem(2, R.id.navigation_drawer); //select movie activity
waitForPanelState(BottomSheetBehavior.STATE_COLLAPSED);
expandPanel();
onView(isRoot()).perform(ViewActions.waitForView(R.id.mpi_seek_bar, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
int seekBarProgress = ((SeekBar) v).getProgress();
return (seekBarProgress > progress) && (seekBarProgress < (progress + 4));
}
}, 10000));
}
/**
* Test if pause button pauses playback
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Pause playback
* 3. Result: Server should stop playing and progressbar should pause
*/
@Test
public void pausePlayback() {
onView(withId(R.id.play)).perform(click());
assertSame(getPlayerHandler().getPlayState(), PlayerHandler.PLAY_STATE.PAUSED);
expandPanel();
final int progress = ((SeekBar) getActivity().findViewById(R.id.mpi_seek_bar)).getProgress();
SystemClock.sleep(1000); //wait one second to check if progression has indeed paused
onView(isRoot()).perform(ViewActions.waitForView(R.id.mpi_seek_bar, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
int seekBarProgress = ((SeekBar) v).getProgress();
return seekBarProgress == progress;
}
}, 10000));
}
/**
* Test if panel is not displayed when user disables the panel
* through the preference screen
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Disable showing panel in settings
* 3. Result: Panel should not show
*/
@Test
public void disableShowingPanelInPreferences() throws Throwable {
Utils.openDrawer(getActivityTestRule());
clickAdapterViewItem(10, R.id.navigation_drawer); //Show preference screen
SharedPreferences.Editor edit = PreferenceManager.getDefaultSharedPreferences(getActivity()).edit();
edit.putBoolean(Settings.KEY_PREF_SHOW_NOW_PLAYING_PANEL, false);
edit.apply();
pressBack();
waitForPanelState(BottomSheetBehavior.STATE_HIDDEN);
}
/**
* Test if panel is displayed when user enables the panel
* through the preference screen
*
* UI interaction flow tested:
* 1. Start playing a music item
* 2. Disable showing panel in settings
* 3. Show Music screen
* 4. Enable showing panel in settings
* 4. Return to Music screen
* 5. Result: Panel should show
*/
@Test
public void showPanelWhenUserEnablesPanel() throws Throwable {
Utils.openDrawer(getActivityTestRule());
clickAdapterViewItem(10, R.id.navigation_drawer); //Show preference screen
SharedPreferences.Editor edit = PreferenceManager.getDefaultSharedPreferences(getActivity()).edit();
edit.putBoolean(Settings.KEY_PREF_SHOW_NOW_PLAYING_PANEL, false);
edit.apply();
pressBack();
Utils.openDrawer(getActivityTestRule());
clickAdapterViewItem(10, R.id.navigation_drawer); //Show preference screen
edit.putBoolean(Settings.KEY_PREF_SHOW_NOW_PLAYING_PANEL, true);
edit.apply();
pressBack();
waitForPanelState(BottomSheetBehavior.STATE_COLLAPSED);
}
private void expandPanel() {
int tries = 10;
while (tries-- > 0) {
try {
onView(withId(R.id.title)).perform(click());
onView(isRoot()).perform(ViewActions.waitForView(R.id.now_playing_panel, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((NowPlayingPanel) v).getPanelState() == BottomSheetBehavior.STATE_EXPANDED;
}
}, 1000));
return;
} catch (Exception e) {
//Either the click event did not work or the panel did not expand.
//Let's try again.
}
}
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.remote.controlpad.eventserver;
import android.content.Context;
import androidx.test.rule.ActivityTestRule;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.eventclient.ButtonCodes;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.host.HostManager;
import org.xbmc.kore.jsonrpc.method.Input;
import org.xbmc.kore.testhelpers.TestUtils;
import org.xbmc.kore.testhelpers.Utils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.testutils.eventserver.EventPacket;
import org.xbmc.kore.testutils.eventserver.EventPacketBUTTON;
import org.xbmc.kore.testutils.eventserver.MockEventServer;
import org.xbmc.kore.ui.sections.remote.RemoteActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static junit.framework.Assert.assertTrue;
public class ButtonTests extends AbstractTestClass<RemoteActivity> {
private static MockEventServer mockEventServer;
@Rule
public ActivityTestRule<RemoteActivity> remoteActivityActivityTestRule =
new ActivityTestRule<>(RemoteActivity.class);
@Override
protected ActivityTestRule<RemoteActivity> getActivityTestRule() {
return remoteActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
Utils.setUseEventServerPreference(context, true);
}
@BeforeClass
public static void setupEventServer() {
mockEventServer = new MockEventServer();
mockEventServer.setListenPort(HostInfo.DEFAULT_EVENT_SERVER_PORT);
mockEventServer.start();
}
@Override
public void setUp() throws Throwable {
setKodiMajorVersion(HostInfo.KODI_V17_KRYPTON);
super.setUp();
}
@After
public void resetState() {
mockEventServer.reset();
}
@AfterClass
public static void cleanup() {
mockEventServer.shutdown();
}
@Test
public void leftControlPadButtonTest() {
onView(withId(R.id.left)).perform(click());
testRemoteButton(ButtonCodes.REMOTE_LEFT);
}
@Test
public void rightControlPadButtonTest() {
onView(withId(R.id.right)).perform(click());
testRemoteButton(ButtonCodes.REMOTE_RIGHT);
}
@Test
public void upControlPadButtonTest() {
onView(withId(R.id.up)).perform(click());
testRemoteButton(ButtonCodes.REMOTE_UP);
}
@Test
public void downControlPadButtonTest() {
onView(withId(R.id.down)).perform(click());
testRemoteButton(ButtonCodes.REMOTE_DOWN);
}
@Test
public void selectPadButtonTest() {
onView(withId(R.id.select)).perform(click());
testRemoteButton(ButtonCodes.REMOTE_SELECT);
}
//The following tests do not use the event server. They're included here
//to make sure they still work when the event server is enabled.
@Test
public void contextControlPadButtonTest() {
onView(withId(R.id.context)).perform(click());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.CONTEXTMENU);
}
@Test
public void infoControlPadButtonTest() {
HostManager.getInstance(getActivity()).getHostInfo().setKodiVersionMajor(17);
onView(withId(R.id.info)).perform(click());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.INFO);
}
@Test
public void infoControlPadButtonLongClickTest() {
onView(withId(R.id.info)).perform(longClick());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.PLAYERPROCESSINFO);
}
@Test
public void osdControlPadButtonTest() {
onView(withId(R.id.osd)).perform(click());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.OSD);
}
@Test
public void backControlPadButtonTest() {
onView(withId(R.id.back)).perform(click());
TestUtils.testHTTPEvent(Input.Back.METHOD_NAME, null);
}
private void testRemoteButton(String buttonName) {
EventPacket packet = mockEventServer.getEventPacket();
assertTrue(packet != null);
assertTrue(packet.getPacketType() == EventPacket.PT_BUTTON);
assertTrue(((EventPacketBUTTON) packet).getButtonName().contentEquals(buttonName));
assertTrue(((EventPacketBUTTON) packet).getMapName().contentEquals(ButtonCodes.MAP_REMOTE));
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.remote.controlpad.eventserver;
import android.content.Context;
import androidx.test.rule.ActivityTestRule;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.jsonrpc.method.Input;
import org.xbmc.kore.testhelpers.Utils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.testutils.eventserver.MockEventServer;
import org.xbmc.kore.ui.sections.remote.RemoteActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static junit.framework.Assert.assertTrue;
public class KodiPreV17Tests extends AbstractTestClass<RemoteActivity> {
private static MockEventServer mockEventServer;
@Rule
public ActivityTestRule<RemoteActivity> remoteActivityActivityTestRule =
new ActivityTestRule<>(RemoteActivity.class);
@Override
protected ActivityTestRule<RemoteActivity> getActivityTestRule() {
return remoteActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
Utils.setUseEventServerPreference(context, true);
}
@BeforeClass
public static void setupEventServer() {
mockEventServer = new MockEventServer();
mockEventServer.setListenPort(HostInfo.DEFAULT_EVENT_SERVER_PORT);
mockEventServer.start();
}
@Override
public void setUp() throws Throwable {
setKodiMajorVersion(HostInfo.KODI_V16_JARVIS);
super.setUp();
}
@After
public void resetState() {
mockEventServer.reset();
}
@AfterClass
public static void cleanup() {
mockEventServer.shutdown();
}
@Test
public void infoControlPadButtonLongClickTest() {
onView(withId(R.id.info)).perform(longClick());
String actionReceived = getInputHandler().getAction();
assertTrue(actionReceived != null);
assertTrue(actionReceived.contentEquals(Input.ExecuteAction.CODECINFO));
}
}

View file

@ -0,0 +1,132 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.remote.controlpad.http;
import android.content.Context;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.host.HostManager;
import org.xbmc.kore.jsonrpc.method.Input;
import org.xbmc.kore.testhelpers.TestUtils;
import org.xbmc.kore.testhelpers.Utils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.remote.RemoteActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
public class ButtonTests extends AbstractTestClass<RemoteActivity> {
@Rule
public ActivityTestRule<RemoteActivity> remoteActivityActivityTestRule =
new ActivityTestRule<>(RemoteActivity.class);
@Override
protected ActivityTestRule<RemoteActivity> getActivityTestRule() {
return remoteActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
Utils.setUseEventServerPreference(context, false);
}
@Override
public void setUp() throws Throwable {
setKodiMajorVersion(HostInfo.KODI_V17_KRYPTON);
super.setUp();
}
@Test
public void leftControlPadButtonTest() {
onView(withId(R.id.left)).perform(click());
TestUtils.testHTTPEvent(Input.Left.METHOD_NAME, null);
}
@Test
public void rightControlPadButtonTest() {
onView(withId(R.id.right)).perform(click());
TestUtils.testHTTPEvent(Input.Right.METHOD_NAME, null);
}
@Test
public void upControlPadButtonTest() {
onView(withId(R.id.up)).perform(click());
TestUtils.testHTTPEvent(Input.Up.METHOD_NAME, null);
}
@Test
public void downControlPadButtonTest() {
onView(withId(R.id.down)).perform(click());
TestUtils.testHTTPEvent(Input.Down.METHOD_NAME, null);
}
@Test
public void selectPadButtonTest() {
onView(withId(R.id.select)).perform(click());
TestUtils.testHTTPEvent(Input.Select.METHOD_NAME, null);
}
@Test
public void contextControlPadButtonTest() {
onView(withId(R.id.context)).perform(click());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.CONTEXTMENU);
}
@Test
public void infoControlPadButtonTest() {
HostManager.getInstance(getActivity()).getHostInfo().setKodiVersionMajor(17);
onView(withId(R.id.info)).perform(click());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.INFO);
}
@Test
public void infoControlPadButtonLongClickTest() {
onView(withId(R.id.info)).perform(longClick());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.PLAYERPROCESSINFO);
}
@Test
public void osdControlPadButtonTest() {
onView(withId(R.id.osd)).perform(click());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.OSD);
}
@Test
public void backControlPadButtonTest() {
onView(withId(R.id.back)).perform(click());
TestUtils.testHTTPEvent(Input.Back.METHOD_NAME, null);
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.remote.controlpad.http;
import android.content.Context;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.jsonrpc.method.Input;
import org.xbmc.kore.testhelpers.TestUtils;
import org.xbmc.kore.testhelpers.Utils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.remote.RemoteActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
public class KodiPreV17Tests extends AbstractTestClass<RemoteActivity> {
@Rule
public ActivityTestRule<RemoteActivity> remoteActivityActivityTestRule =
new ActivityTestRule<>(RemoteActivity.class);
@Override
protected ActivityTestRule<RemoteActivity> getActivityTestRule() {
return remoteActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
Utils.setUseEventServerPreference(context, false);
}
@Override
public void setUp() throws Throwable {
setKodiMajorVersion(HostInfo.KODI_V16_JARVIS);
super.setUp();
}
@Test
public void infoControlPadButtonLongClickTest() {
onView(withId(R.id.info)).perform(longClick());
TestUtils.testHTTPEvent(Input.ExecuteAction.METHOD_NAME, Input.ExecuteAction.CODECINFO);
}
}

View file

@ -0,0 +1,294 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.remote.playlistfragment.TCP;
import android.content.Context;
import android.view.View;
import android.widget.TextView;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.testhelpers.action.ViewActions;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.testutils.tcpserver.handlers.PlayerHandler;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Player;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Playlist;
import org.xbmc.kore.ui.sections.remote.RemoteActivity;
import java.util.List;
import java.util.concurrent.TimeoutException;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.swipeLeft;
import static androidx.test.espresso.action.ViewActions.swipeRight;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.allOf;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.xbmc.kore.testutils.TestUtils.createMusicItem;
import static org.xbmc.kore.testutils.TestUtils.createPictureItem;
import static org.xbmc.kore.testutils.TestUtils.createVideoItem;
import static org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Playlist.OnClear;
public class PlaylistTests extends AbstractTestClass<RemoteActivity> {
private static final int PLAYLIST_SIZE = 10;
@Rule
public ActivityTestRule<RemoteActivity> remoteActivityActivityTestRule =
new ActivityTestRule<>(RemoteActivity.class);
@Override
protected ActivityTestRule<RemoteActivity> getActivityTestRule() {
return remoteActivityActivityTestRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
@Override
public void setUp() throws Throwable {
int itemId = 0;
getPlaylistHandler().reset();
for (int i = 0; i < PLAYLIST_SIZE; i++) {
getPlaylistHandler().addItemToPlaylist(Playlist.playlistID.AUDIO, createMusicItem(i, itemId++), false);
getPlaylistHandler().addItemToPlaylist(Playlist.playlistID.VIDEO, createVideoItem(i, itemId++), false);
getPlaylistHandler().addItemToPlaylist(Playlist.playlistID.PICTURE, createPictureItem(i, itemId++), false);
}
getPlayerHandler().reset();
getPlayerHandler().setPlaylists(getPlaylistHandler().getPlaylists());
getPlayerHandler().startPlay(Playlist.playlistID.AUDIO, 0);
// Checking for available playlists is done in PlaylistFragment on startup
// and every 10 seconds. To make sure PlaylistFragment can get the available
// playlists at startup, the activity needs to be created after the backend
// has been fully setup.
super.setUp();
onView(isRoot()).perform(swipeLeft());
waitForAudioPlaylistToShow();
}
/**
* Test if playlist is not cleared when playback is stopped
*
* UI interaction flow tested:
* 1. Start playing multiple music items
* 2. Stop playback
* 3. Result: playlist should still be visible
*/
@Test
public void keepPlaylistOnStop() {
onView(isRoot()).perform(swipeRight());
EspressoTestUtils.clickButton(R.id.stop);
onView(isRoot()).perform(swipeLeft());
assertEquals(getPlaylistHandler().getPlaylist(Playlist.playlistID.AUDIO).size(), PLAYLIST_SIZE);
EspressoTestUtils.checkListViewSize(PLAYLIST_SIZE, R.id.playlist);
}
/**
* Test if playlist is not cleared when playback is paused
*
* UI interaction flow tested:
* 1. Start playing multiple music items
* 2. Pause playback
* 3. Result: playlist should still be visible
*/
@Test
public void keepPlaylistOnPause() {
onView(isRoot()).perform(swipeRight());
EspressoTestUtils.clickButton(R.id.play);
onView(isRoot()).perform(swipeLeft());
assertEquals(getPlaylistHandler().getPlaylist(Playlist.playlistID.AUDIO).size(), PLAYLIST_SIZE);
EspressoTestUtils.checkListViewSize(PLAYLIST_SIZE, R.id.playlist);
}
/**
* Test if playlist is cleared when cleared on Kodi
*
* UI interaction flow tested:
* 1. Start playing multiple music items
* 2. Clear playlist on server (Kodi)
* 3. Result: playlist should be empty
*/
@Test
public void clearPlaylistWhenClearedOnKodi() throws Exception {
getPlaylistHandler().clearPlaylist(Playlist.playlistID.AUDIO);
getConnectionHandlerManager().waitForNotification(OnClear.METHOD_NAME, 10000);
assertEquals(0, getPlaylistHandler().getPlaylist(Playlist.playlistID.AUDIO).size());
onView(allOf(withId(R.id.info_title), withText(R.string.playlist_empty)))
.check(matches(isDisplayed()));
}
/**
* Test if playback of a playlist is resumed after stopping playback
*
* UI interaction flow tested:
* 1. Start playing multiple music items
* 2. Stop playback
* 3. Click on playlist item
* 4. Result: playback should resume from clicked playlist item
*/
@Test
public void stopPlayingAndResumeNextItem() throws TimeoutException {
int positionClicked = 3;
onView(isRoot()).perform(swipeRight());
EspressoTestUtils.clickButton(R.id.stop);
onView(isRoot()).perform(swipeLeft());
getConnectionHandlerManager().clearMethodsHandled();
EspressoTestUtils.clickAdapterViewItem(positionClicked, R.id.playlist);
getConnectionHandlerManager().waitForMethodHandled(Player.Open.METHOD_NAME, 10000);
List<Player.GetItem> playlistOnServer = getPlaylistHandler().getPlaylist(Playlist.playlistID.AUDIO);
assertSame(getPlayerHandler().getPlayState(), PlayerHandler.PLAY_STATE.PLAYING);
assertEquals("Playlist on server has size " + playlistOnServer.size() +
" but should be " + PLAYLIST_SIZE, playlistOnServer.size(), PLAYLIST_SIZE);
assertEquals("Current playing item ID is " + getPlayerHandler().getMediaItem().getLibraryId() +
", but this should be " + playlistOnServer.get(positionClicked).getLibraryId(),
getPlayerHandler().getMediaItem().getLibraryId(), playlistOnServer.get(positionClicked).getLibraryId());
}
/**
* Test if playlist is correctly restored after playback has stopped
* and device configuration changed
* UI interaction flow tested:
* 1. Start playing multiple music items
* 2. Rotate device
* 3. Result: playlist should be the same as before rotation
*/
@Test
public void restorePlaylistAfterConfigurationChange() {
getConnectionHandlerManager().clearMethodsHandled();
EspressoTestUtils.rotateDevice(getActivity());
waitForAudioPlaylistToShow();
assertEquals(getPlaylistHandler().getPlaylist(Playlist.playlistID.AUDIO).size(), PLAYLIST_SIZE);
EspressoTestUtils.checkListViewSize(PLAYLIST_SIZE, R.id.playlist);
}
/**
* Test if playlist is correctly restored after playback has stopped
* and device configuration changed
* UI interaction flow tested:
* 1. Start playing multiple music items
* 2. Stop playback
* 3. Rotate device
* 4. Result: playlist should be the same as before rotation
*/
@Test
public void restorePlaylistAfterStopAndConfigurationChange() {
onView(isRoot()).perform(swipeRight());
EspressoTestUtils.clickButton(R.id.stop);
onView(isRoot()).perform(swipeLeft());
getConnectionHandlerManager().clearMethodsHandled();
EspressoTestUtils.rotateDevice(getActivity());
waitForAudioPlaylistToShow();
assertEquals(getPlaylistHandler().getPlaylist(Playlist.playlistID.AUDIO).size(), PLAYLIST_SIZE);
EspressoTestUtils.checkListViewSize(PLAYLIST_SIZE, R.id.playlist);
}
/**
* Test if playlist for currently playing item is shown even if other
* playlists are available on server
* UI interaction flow tested:
* 1. Add audio and video playlists on server
* 2. Start playing video item
* 3. Result: playlist for video items should be shown
*/
@Test
public void showCurrentlyPlayingPlaylist() {
getPlayerHandler().startPlay(Playlist.playlistID.VIDEO, 0);
onView(isRoot()).perform(ViewActions.waitForView(R.id.playlist_item_title, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((TextView) v).getText().toString().contains("Video");
}
}, 10000));
assertEquals("Playlist on server has size "
+ getPlaylistHandler().getPlaylist(Playlist.playlistID.VIDEO).size() +
" but should be " + PLAYLIST_SIZE,
getPlaylistHandler().getPlaylist(Playlist.playlistID.VIDEO).size(), PLAYLIST_SIZE);
assertEquals("Got media type "
+ getPlayerHandler().getMediaItem().getType() +
", this should be " + Player.GetItem.TYPE.movie.name(),
getPlayerHandler().getMediaItem().getType(), Player.GetItem.TYPE.movie.name());
onView(allOf(withText(getPlayerHandler().getMediaItem().getTitle()), isDisplayed())).check(matches(isDisplayed()));
}
/**
* Test if playlist for last played item is shown when playback has stopped
* and other playlists are available on server
* UI interaction flow tested:
* 1. Add audio, picture, and video playlists on server
* 2. Start playing video item
* 3. Stop playback
* 4. Result: playlist for video items should be shown
*/
@Test
public void showLastActivePlaylist() {
getPlayerHandler().startPlay(Playlist.playlistID.VIDEO, 0);
onView(isRoot()).perform(swipeRight());
EspressoTestUtils.clickButton(R.id.stop);
onView(isRoot()).perform(swipeLeft());
onView(isRoot()).perform(ViewActions.waitForView(R.id.playlist_item_title, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return ((TextView) v).getText().toString().contains("Video");
}
}, 10000));
assertEquals("Playlist on server has size "
+ getPlaylistHandler().getPlaylist(Playlist.playlistID.VIDEO).size() +
" but should be " + PLAYLIST_SIZE,
getPlaylistHandler().getPlaylist(Playlist.playlistID.VIDEO).size(), PLAYLIST_SIZE);
assertEquals("Got media type "
+ getPlayerHandler().getMediaItem().getType() +
", this should be " + Player.GetItem.TYPE.movie.name(),
getPlayerHandler().getMediaItem().getType(), Player.GetItem.TYPE.movie.name());
onView(allOf(withText(getPlayerHandler().getMediaItem().getTitle()), isDisplayed())).check(matches(isDisplayed()));
}
private void waitForAudioPlaylistToShow() {
onView(isRoot()).perform(ViewActions.waitForView(R.id.playlist_item_title, new ViewActions.CheckStatus() {
@Override
public boolean check(View v) {
return "Music 1".contentEquals(((TextView) v).getText());
}
}, 10000));
}
}

View file

@ -0,0 +1,282 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.tests.ui.tvshows;
import android.content.Context;
import android.widget.TextView;
import androidx.test.espresso.Espresso;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.xbmc.kore.R;
import org.xbmc.kore.testhelpers.EspressoTestUtils;
import org.xbmc.kore.tests.ui.AbstractTestClass;
import org.xbmc.kore.ui.sections.video.TVShowsActivity;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.clickRecyclerViewItem;
import static org.xbmc.kore.testhelpers.EspressoTestUtils.rotateDevice;
import static org.xbmc.kore.testhelpers.action.ViewActions.nestedScrollTo;
public class TVShowsActivityTests extends AbstractTestClass<TVShowsActivity> {
private final String TV_SHOW_TITLE = "11.22.63";
private final String EPISODE_TITLE = "The Rabbit Hole";
@Rule
public ActivityTestRule<TVShowsActivity> mActivityRule = new ActivityTestRule<>(
TVShowsActivity.class);
@Override
protected ActivityTestRule<TVShowsActivity> getActivityTestRule() {
return mActivityRule;
}
@Override
protected void setSharedPreferences(Context context) {
}
/**
* Test if action bar title initially displays TV Shows
*/
@Test
public void setActionBarTitleMain() {
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText(R.string.tv_shows)));
}
/**
* Test if action bar title is correctly set after selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: action bar title should show list item title
*/
@Test
public void setActionBarTitle() {
EspressoTestUtils.selectListItemAndCheckActionbarTitle(TV_SHOW_TITLE, R.id.list, TV_SHOW_TITLE);
}
/**
* Test if action bar title is correctly set after selecting a season
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Click on next episode item
* 3. Result: action bar title should show next episode title
*/
@Test
public void setActionBarTitleOnNextEpisode() {
clickRecyclerViewItem(1, R.id.list);
onView( withId(R.id.next_episode_list)).perform( nestedScrollTo(), click());
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText("3")));
}
/**
* Test if action bar title is correctly set after selecting a season
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Click on season item
* 3. Result: action bar title should show season title
*/
@Test
public void setActionBarTitleOnSeasonList() {
clickRecyclerViewItem(0, R.id.list);
onView( withId(R.id.seasons_list)).perform(nestedScrollTo(), click());
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText("Season 01")));
}
/**
* Test if action bar title is correctly set after selecting an episode from the season list
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Click on season item
* 3. Click on an episode
* 4. Result: action bar title should show episode title
*/
@Test
public void setActionBarTitleOnSeasonListEpisode() {
clickRecyclerViewItem(0, R.id.list);
onView( withId(R.id.seasons_list)).perform( nestedScrollTo(), click());
EspressoTestUtils.selectListItemAndCheckActionbarTitle(EPISODE_TITLE, R.id.list, TV_SHOW_TITLE);
}
/**
* Test if action bar title is correctly restored after a configuration change
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Rotate device
* 3. Result: action bar title should show TV show item title
*/
@Test
public void restoreActionBarTitleOnConfigurationStateChanged() {
EspressoTestUtils.selectListItemRotateDeviceAndCheckActionbarTitle(TV_SHOW_TITLE, R.id.list,
TV_SHOW_TITLE,
mActivityRule.getActivity());
}
/**
* Test if action bar title is correctly restored on season list after a configuration change
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Click on season item
* 3. Rotate device
* 4. Result: action bar title should show season title
*/
@Test
public void restoreActionBarTitleSeasonListOnConfigurationStateChanged() {
clickRecyclerViewItem(0, R.id.list);
onView( withId(R.id.seasons_list)).perform( nestedScrollTo(), click());
EspressoTestUtils.rotateDevice(mActivityRule.getActivity());
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText("Season 01")));
}
/**
* Test if action bar title is correctly restored on episode item title after a configuration change
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Click on season item
* 3. Click on episode item
* 4. Rotate device
* 5. Result: action bar title should TV show title
*/
@Test
public void restoreActionBarTitleSeasonListEpisodeOnConfigurationStateChanged() {
clickRecyclerViewItem(0, R.id.list);
onView( withId(R.id.seasons_list)).perform( nestedScrollTo(), click());
EspressoTestUtils.selectListItemRotateDeviceAndCheckActionbarTitle(EPISODE_TITLE, R.id.list,
TV_SHOW_TITLE,
mActivityRule.getActivity());
}
/**
* Test if action bar title is correctly restored on next episode item title after a configuration change
*
* UI interaction flow tested:
* 1. Click on TV Show item
* 2. Click on next episode item
* 3. Rotate device
* 4. Result: action bar title should show season title
*/
@Test
public void restoreActionBarTitleNextEpisodeOnConfigurationStateChanged() {
clickRecyclerViewItem(1, R.id.list);
onView( withId(R.id.next_episode_list)).perform( nestedScrollTo() );
onView( withText("You'll See the Sparkle")).perform( click() );
EspressoTestUtils.rotateDevice(mActivityRule.getActivity());
onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.default_toolbar))))
.check(matches(withText("3")));
}
/**
* Test if the initial state shows the hamburger icon
*/
@Test
public void showHamburgerInInitialState() {
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Result: navigation icon should be an arrow
*/
@Test
public void showArrowWhenSelectingListItem() {
clickRecyclerViewItem(0, R.id.list);
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is changed to an arrow when selecting a list item
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Press back
* 3. Result: navigation icon should be a hamburger
*/
@Test
public void showHamburgerWhenSelectingListItemAndReturn() {
clickRecyclerViewItem(0, R.id.list);
Espresso.pressBack();
assertFalse(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an arrow when selecting a list item
* and rotating the device
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Result: navigation icon should be an arrow
*/
@Test
public void restoreArrowOnConfigurationChange() {
clickRecyclerViewItem(0, R.id.list);
rotateDevice(getActivity());
assertTrue(getActivity().getDrawerIndicatorIsArrow());
}
/**
* Test if navigation icon is restored to an hamburger when selecting a list item
* and rotating the device and returning to the list
*
* UI interaction flow tested:
* 1. Click on list item
* 2. Rotate device
* 3. Press back
* 4. Result: navigation icon should be a hamburger
*/
@Test
public void restoreHamburgerOnConfigurationChangeOnReturn() {
clickRecyclerViewItem(0, R.id.list);
rotateDevice(getActivity());
Espresso.pressBack();
assertTrue(EspressoTestUtils.getActivity() instanceof TVShowsActivity);
assertFalse(((TVShowsActivity) EspressoTestUtils.getActivity()).getDrawerIndicatorIsArrow());
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- For espresso testing purposes, this is removed in live builds, but not in dev builds -->
<uses-permission android:name="android.permission.SET_ANIMATION_SCALE"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
</manifest>

5
app/src/debug/README.md Normal file
View file

@ -0,0 +1,5 @@
Resources required for both the local and instrumentation tests.
**Note**: do not put any tests here! Put local tests
that DO NOT need to be executed on an android device in [test](../test).
Put tests that DO need to run on an android device in [androidTest](../androidTest).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,247 @@
{
"id" : "libGenres",
"result" : {
"limits" : {
"total" : 39,
"start" : 0,
"end" : 39
},
"genres" : [
{
"genreid" : 9,
"title" : "Ambient",
"label" : "Ambient",
"thumbnail" : ""
},
{
"label" : "Bluegrass",
"thumbnail" : "",
"genreid" : 33,
"title" : "Bluegrass"
},
{
"title" : "Blues",
"genreid" : 1,
"thumbnail" : "",
"label" : "Blues"
},
{
"label" : "Blues Compilation",
"thumbnail" : "",
"genreid" : 35,
"title" : "Blues Compilation"
},
{
"thumbnail" : "",
"label" : "Brutal Death Metal",
"title" : "Brutal Death Metal",
"genreid" : 32
},
{
"title" : "Celtic",
"genreid" : 24,
"thumbnail" : "",
"label" : "Celtic"
},
{
"genreid" : 11,
"title" : "Classic for Kids",
"label" : "Classic for Kids",
"thumbnail" : ""
},
{
"thumbnail" : "",
"label" : "Classical",
"title" : "Classical",
"genreid" : 3
},
{
"genreid" : 13,
"title" : "Country",
"label" : "Country",
"thumbnail" : ""
},
{
"title" : "Dancehall",
"genreid" : 31,
"thumbnail" : "",
"label" : "Dancehall"
},
{
"title" : "Easy Listening",
"genreid" : 20,
"thumbnail" : "",
"label" : "Easy Listening"
},
{
"title" : "Folk",
"genreid" : 4,
"thumbnail" : "",
"label" : "Folk"
},
{
"label" : "Folklore",
"thumbnail" : "",
"genreid" : 22,
"title" : "Folklore"
},
{
"label" : "Goregrind",
"thumbnail" : "",
"genreid" : 14,
"title" : "Goregrind"
},
{
"genreid" : 8,
"title" : "Grind Core",
"label" : "Grind Core",
"thumbnail" : ""
},
{
"title" : "Grindcore",
"genreid" : 18,
"thumbnail" : "",
"label" : "Grindcore"
},
{
"label" : "Hardcore",
"thumbnail" : "",
"genreid" : 12,
"title" : "Hardcore"
},
{
"label" : "Hardcore Thrash",
"thumbnail" : "",
"genreid" : 30,
"title" : "Hardcore Thrash"
},
{
"title" : "Hip-Hop",
"genreid" : 17,
"thumbnail" : "",
"label" : "Hip-Hop"
},
{
"title" : "Instrumental",
"genreid" : 39,
"thumbnail" : "",
"label" : "Instrumental"
},
{
"genreid" : 5,
"title" : "Jazz",
"label" : "Jazz",
"thumbnail" : ""
},
{
"label" : "Jazz+Funk",
"thumbnail" : "",
"genreid" : 21,
"title" : "Jazz+Funk"
},
{
"thumbnail" : "",
"label" : "Metal",
"title" : "Metal",
"genreid" : 10
},
{
"label" : "Oldies",
"thumbnail" : "",
"genreid" : 36,
"title" : "Oldies"
},
{
"genreid" : 23,
"title" : "Other",
"label" : "Other",
"thumbnail" : ""
},
{
"title" : "Pop",
"genreid" : 25,
"thumbnail" : "",
"label" : "Pop"
},
{
"thumbnail" : "",
"label" : "porno grind",
"title" : "porno grind",
"genreid" : 26
},
{
"title" : "Punk",
"genreid" : 7,
"thumbnail" : "",
"label" : "Punk"
},
{
"label" : "Punk Rock",
"thumbnail" : "",
"genreid" : 16,
"title" : "Punk Rock"
},
{
"title" : "Rap",
"genreid" : 28,
"thumbnail" : "",
"label" : "Rap"
},
{
"title" : "Reggae",
"genreid" : 2,
"thumbnail" : "",
"label" : "Reggae"
},
{
"title" : "Rhythm and Blues",
"genreid" : 19,
"thumbnail" : "",
"label" : "Rhythm and Blues"
},
{
"title" : "Rock",
"genreid" : 34,
"thumbnail" : "",
"label" : "Rock"
},
{
"genreid" : 15,
"title" : "Salsa",
"label" : "Salsa",
"thumbnail" : ""
},
{
"thumbnail" : "",
"label" : "Soul",
"title" : "Soul",
"genreid" : 29
},
{
"thumbnail" : "",
"label" : "Soundtrack",
"title" : "Soundtrack",
"genreid" : 6
},
{
"thumbnail" : "",
"label" : "Sprachkurs",
"title" : "Sprachkurs",
"genreid" : 27
},
{
"title" : "Κινηματογραφική",
"genreid" : 37,
"thumbnail" : "",
"label" : "Κινηματογραφική"
},
{
"title" : "ゲーム音楽",
"genreid" : 38,
"thumbnail" : "",
"label" : "ゲーム音楽"
}
]
},
"jsonrpc" : "2.0"
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,581 @@
{
"id" : "libMovies",
"jsonrpc" : "2.0",
"result" : {
"musicvideos" : [
{
"album" : "...Baby One More Time",
"director" : [
"Nigel Dick"
],
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fbaby-one-more-time-4dcff7453745a.jpg/"
},
"plot" : "\"(You Drive Me) Crazy\" is a song by American recording artist Britney Spears from her debut studio album, ...Baby One More Time (1999). Written and produced by Max Martin, Per Magnusson and David Kreuger, with additional writing by Jörgen Elofsson and remix by Martin and Rami Yacoub, it was released as the album's third single on August 23, 1999 by Jive Records. It was remixed for the soundtrack of Drive Me Crazy. \"(You Drive Me) Crazy\" is a teen pop song that draws influences from R&B and rock. The song garnered positive reviews from music critics, some of whom praised its simple formula and noted similarities to Spears' debut single, \"...Baby One More Time\".\n\n\"(You Drive Me) Crazy\" was a commercial success, and peaked inside the top ten on the singles charts of seventeen countries. In the United Kingdom, it became Spears' third consecutive single to peak inside the top five, while it reached number 10 in the United States' Hot 100, and peaked at number one in Belgium (Wallonia). An accompanying music video, directed by Nigel Dick, and portrayed Spears as a waitress of a dance club, and performed a highly choreographed dance routine with the other waitresses. The video premiered on MTV's Making the Video special, and featured cameo appearances of actors Melissa Joan Hart and Adrien Grenier. As part of promotion for the song, Spears performed the song at the 1999 MTV Europe Music Awards and 1999 Billboard Music Awards. It has also been included on five of her concert tours.",
"resume" : {
"position" : 0,
"total" : 0
},
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fbaby-one-more-time-4dcff7453745a.jpg/",
"fanart" : "",
"rating" : 0,
"year" : 1999,
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Britney Spears - (You Drive Me) Crazy (1999).mp4",
"dateadded" : "2016-12-29 16:50:28",
"userrating" : 0,
"artist" : [
"Britney Spears"
],
"lastplayed" : "2017-02-28 11:07:15",
"studio" : [],
"tag" : [],
"title" : "(You Drive Me) Crazy",
"label" : "(You Drive Me) Crazy",
"runtime" : 12,
"track" : -1,
"genre" : [
"Pop"
],
"streamdetails" : {
"audio" : [
{
"channels" : 2,
"language" : "und",
"codec" : "aac"
}
],
"video" : [
{
"width" : 480,
"language" : "und",
"height" : 360,
"duration" : 12,
"stereomode" : "",
"aspect" : 1.33333301544189,
"codec" : "h264"
}
],
"subtitle" : []
},
"musicvideoid" : 60,
"premiered" : "1999-01-01",
"playcount" : 1
},
{
"resume" : {
"total" : 0,
"position" : 0
},
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fsvpxyw1364737910.jpg/"
},
"director" : [],
"plot" : "",
"album" : "Rubber Factory",
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/The Black Keys - 10 A.M. Automatic (2004).mp4",
"rating" : 0,
"year" : 2004,
"fanart" : "",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fsvpxyw1364737910.jpg/",
"track" : -1,
"label" : "10 A.M. Automatic",
"runtime" : 12,
"lastplayed" : "",
"studio" : [],
"title" : "10 A.M. Automatic",
"tag" : [],
"dateadded" : "2016-12-29 16:50:29",
"artist" : [
"The Black Keys"
],
"userrating" : 0,
"premiered" : "2004-01-01",
"playcount" : 0,
"musicvideoid" : 370,
"streamdetails" : {
"subtitle" : [],
"audio" : [
{
"channels" : 2,
"language" : "und",
"codec" : "aac"
}
],
"video" : [
{
"language" : "",
"height" : 360,
"width" : 480,
"duration" : 12,
"stereomode" : "",
"aspect" : 1.33333301544189,
"codec" : "avc1"
}
]
},
"genre" : [
"Indie"
]
},
{
"runtime" : 12,
"label" : "99 Problems",
"track" : -1,
"userrating" : 0,
"artist" : [
"Jay-Z"
],
"dateadded" : "2016-12-29 16:50:28",
"title" : "99 Problems",
"tag" : [],
"studio" : [
"Anonymous Content"
],
"lastplayed" : "",
"musicvideoid" : 164,
"streamdetails" : {
"audio" : [
{
"codec" : "aac",
"channels" : 2,
"language" : "und"
}
],
"video" : [
{
"aspect" : 1.33333301544189,
"stereomode" : "",
"codec" : "avc1",
"duration" : 12,
"width" : 480,
"language" : "",
"height" : 360
}
],
"subtitle" : []
},
"premiered" : "2003-01-01",
"playcount" : 0,
"genre" : [
"Hip-Hop"
],
"director" : [
"Mark Romanek"
],
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fthe-black-album-4ee5475d9f478.jpg/"
},
"plot" : "\"99 Problems\" is the third single released by American rapper Jay-Z in 2004 from The Black Album. The song was originally written by rapper Ice-T in 1993. Throughout the song Jay-Z tells a story about dealing with a racist police officer who wants to illegally search his car, dealing with rap critics, and dealing with an aggressor. The song reached number 30 on the Billboard Hot 100.\nThe track was produced by Rick Rubin, his first hip hop production in many years. Rubin provided Jay-Z with a guitar riff and stripped-down beat that were once his trademarks. In creating the track Rubin used some classic 1980s sample staples such as \"The Big Beat\" by Billy Squier, \"Long Red\" by Mountain, and \"Get Me Back On Time\" by Wilson Pickett. These songs were long coveted by early hip hop producers, in particular the drum beat from Big Beat, used most famously by RunD.M.C. on \"Here We Go\" in 1985 and by British rapper Dizzee Rascal a year prior to Jay-Z on his break-through hit \"Fix Up, Look Sharp\". It also featured on the popular Ultimate Breaks and Beats series.\nWhile the song's meaning is widely debated, the chorus \"If you're having girl problems, I feel bad for you son/I've got 99 problems but a bitch ain't one\" was defined in Jay-Z's book, Decoded, as referring to a police dog. Jay-Z wrote that in 1994 he was pulled over by police while carrying cocaine in a secret compartment in his sunroof. Jay-Z refused to let the police search the car and the police called for the drug sniffing dogs. However, the dogs never showed up and the police had to let Jay-Z go. Moments after he drove away, he wrote that he saw a police car with the dogs drive by.\nThe title and chorus are taken from Ice-T's \"99 Problems\" from his 1993 album Home Invasion. The song featured Brother Marquis of 2 Live Crew. The original song was more profane and describes a wide range of sexual conquests. Portions of Ice-T's original lyrics were similarly quoted in a song by fellow rapper Trick Daddy on a track also titled \"99 Problems\" from his 2001 album Thugs Are Us. Jay-Z begins his third verse directly quoting lines from Bun B's opening verse off the track \"Touched\" from the UGK album Ridin' Dirty.",
"resume" : {
"position" : 0,
"total" : 0
},
"album" : "The Black Album",
"year" : 2003,
"rating" : 0,
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Jay-Z - 99 Problems (2004).mp4",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fthe-black-album-4ee5475d9f478.jpg/",
"fanart" : ""
},
{
"album" : "Dirty",
"resume" : {
"total" : 0,
"position" : 0
},
"plot" : "",
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fdirty-50030abf884f2.jpg/"
},
"director" : [
"Tamra Davis"
],
"fanart" : "",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fdirty-50030abf884f2.jpg/",
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Sonic Youth - 100% (1992).mp4",
"rating" : 0,
"year" : 1992,
"lastplayed" : "",
"studio" : [],
"tag" : [],
"title" : "100%",
"dateadded" : "2016-12-29 16:50:28",
"userrating" : 0,
"artist" : [
"Sonic Youth"
],
"track" : -1,
"label" : "100%",
"runtime" : 12,
"genre" : [
"Alternative Rock"
],
"premiered" : "1992-01-01",
"playcount" : 0,
"streamdetails" : {
"audio" : [
{
"language" : "und",
"channels" : 2,
"codec" : "aac"
}
],
"video" : [
{
"stereomode" : "",
"aspect" : 1.33333301544189,
"codec" : "avc1",
"width" : 480,
"language" : "",
"height" : 360,
"duration" : 12
}
],
"subtitle" : []
},
"musicvideoid" : 349
},
{
"resume" : {
"position" : 0,
"total" : 0
},
"art" : {
"thumb" : "image://video@%2fUsers%2fmartijn%2fProjects%2fdummymediafiles%2fmedia%2fmusicvideos%2fPeter%20Himmelman%20-%20245%20Days%20(1990).mp4/"
},
"director" : [],
"plot" : "",
"album" : "Synesthesia",
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Peter Himmelman - 245 Days (1990).mp4",
"year" : 1989,
"rating" : 0,
"fanart" : "",
"thumbnail" : "image://video@%2fUsers%2fmartijn%2fProjects%2fdummymediafiles%2fmedia%2fmusicvideos%2fPeter%20Himmelman%20-%20245%20Days%20(1990).mp4/",
"track" : -1,
"runtime" : 12,
"label" : "245 Days",
"tag" : [],
"title" : "245 Days",
"lastplayed" : "",
"studio" : [],
"artist" : [
"Peter Himmelman"
],
"userrating" : 0,
"dateadded" : "2016-12-29 16:50:28",
"premiered" : "1989-01-01",
"playcount" : 0,
"streamdetails" : {
"audio" : [
{
"language" : "und",
"channels" : 2,
"codec" : "aac"
}
],
"video" : [
{
"stereomode" : "",
"codec" : "avc1",
"aspect" : 1.33333301544189,
"duration" : 12,
"width" : 480,
"language" : "",
"height" : 360
}
],
"subtitle" : []
},
"musicvideoid" : 297,
"genre" : [
"..."
]
},
{
"dateadded" : "2016-12-29 16:50:28",
"artist" : [
"Public Enemy"
],
"userrating" : 0,
"studio" : [],
"lastplayed" : "2017-03-02 10:43:26",
"tag" : [],
"title" : "911 Is a Joke",
"label" : "911 Is a Joke",
"runtime" : 12,
"track" : -1,
"genre" : [
"Hip-Hop"
],
"streamdetails" : {
"subtitle" : [],
"audio" : [
{
"codec" : "aac",
"language" : "und",
"channels" : 2
}
],
"video" : [
{
"codec" : "h264",
"stereomode" : "",
"aspect" : 1.33333301544189,
"language" : "und",
"height" : 360,
"width" : 480,
"duration" : 12
}
]
},
"musicvideoid" : 306,
"premiered" : "1990-01-01",
"playcount" : 1,
"album" : "Fear of a Black Planet",
"director" : [],
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2ffear-of-a-black-planet-526965153a1e4.jpg/"
},
"plot" : "\"911 Is a Joke\" is a 1990 song by American hip hop group Public Enemy, from their third album, Fear of a Black Planet. The song is solely done by Flavor Flav. It was released as a single and became a hit in June 1990, reaching number 15 on the Hot R&B/Hip-Hop Singles & Tracks chart, and number 1 on the Hot Rap Singles chart, becoming their second number-one rap chart hit after \"Fight the Power\". It also reached number one on the Bubbling Under Hot 100 Singles chart. This was due largely to its sales, which were unusually high for the level of mainstream airplay it received; Billboard reported that only one of the stations on its Top 40 panel was playing it.\n\nThe song is about the lack of response to emergency calls in a black neighborhood, but specifically references the poor response by paramedic crews and not the police, which is a common misconception regarding the track; the \"911\" in the title of the song refers to 9-1-1, the emergency telephone number used in North America.",
"resume" : {
"total" : 0,
"position" : 0
},
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2ffear-of-a-black-planet-526965153a1e4.jpg/",
"fanart" : "",
"rating" : 0,
"year" : 1990,
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Public Enemy - 911 is a Joke (1990).mp4"
},
{
"label" : "A Case of You",
"runtime" : 12,
"track" : -1,
"dateadded" : "2016-12-29 16:50:28",
"userrating" : 0,
"artist" : [
"James Blake"
],
"lastplayed" : "",
"studio" : [],
"tag" : [],
"title" : "A Case of You",
"streamdetails" : {
"audio" : [
{
"channels" : 2,
"language" : "und",
"codec" : "aac"
}
],
"video" : [
{
"width" : 480,
"height" : 360,
"language" : "",
"duration" : 12,
"stereomode" : "",
"aspect" : 1.33333301544189,
"codec" : "avc1"
}
],
"subtitle" : []
},
"musicvideoid" : 160,
"premiered" : "2011-01-01",
"playcount" : 0,
"genre" : [
"Electronic"
],
"director" : [],
"plot" : "",
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2ftyswrr1377556931.jpg/"
},
"resume" : {
"total" : 0,
"position" : 0
},
"album" : "Enough Thunder",
"rating" : 0,
"year" : 2011,
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/James Blake - A Case Of You (2011).mp4",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2ftyswrr1377556931.jpg/",
"fanart" : ""
},
{
"track" : -1,
"label" : "A Little Respect",
"runtime" : 12,
"studio" : [],
"lastplayed" : "",
"tag" : [],
"title" : "A Little Respect",
"dateadded" : "2016-12-29 16:50:28",
"artist" : [
"Wheatus"
],
"userrating" : 0,
"premiered" : "2000-01-01",
"playcount" : 0,
"musicvideoid" : 430,
"streamdetails" : {
"subtitle" : [],
"audio" : [
{
"channels" : 2,
"language" : "und",
"codec" : "aac"
}
],
"video" : [
{
"stereomode" : "",
"codec" : "avc1",
"aspect" : 1.33333301544189,
"duration" : 12,
"language" : "",
"height" : 360,
"width" : 480
}
]
},
"genre" : [
"Rock"
],
"resume" : {
"total" : 0,
"position" : 0
},
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fwheatus-5144f1c42ea0a.jpg/"
},
"plot" : "",
"director" : [],
"album" : "Wheatus",
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Wheatus - A Little Respect (2000).mp4",
"rating" : 0,
"year" : 2000,
"fanart" : "",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fwheatus-5144f1c42ea0a.jpg/"
},
{
"rating" : 0,
"year" : 1996,
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/Counting Crows - A Long December (1996).mp4",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2frecovering-the-satellites-4dcfda1792ec0.jpg/",
"fanart" : "",
"plot" : "",
"director" : [],
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2frecovering-the-satellites-4dcfda1792ec0.jpg/"
},
"resume" : {
"total" : 0,
"position" : 0
},
"album" : "Recovering the Satellites",
"musicvideoid" : 79,
"streamdetails" : {
"video" : [
{
"duration" : 12,
"width" : 480,
"height" : 360,
"language" : "",
"aspect" : 1.33333301544189,
"stereomode" : "",
"codec" : "avc1"
}
],
"audio" : [
{
"language" : "und",
"channels" : 2,
"codec" : "aac"
}
],
"subtitle" : []
},
"premiered" : "1996-01-01",
"playcount" : 0,
"genre" : [
"Alternative Rock"
],
"label" : "A Long December",
"runtime" : 12,
"track" : -1,
"dateadded" : "2016-12-29 16:50:28",
"artist" : [
"Counting Crows"
],
"userrating" : 0,
"studio" : [],
"lastplayed" : "",
"tag" : [],
"title" : "A Long December"
},
{
"genre" : [
"Folk"
],
"premiered" : "1985-01-01",
"playcount" : 0,
"streamdetails" : {
"subtitle" : [],
"audio" : [
{
"channels" : 2,
"language" : "und",
"codec" : "aac"
}
],
"video" : [
{
"stereomode" : "",
"aspect" : 1.33333301544189,
"codec" : "avc1",
"duration" : 12,
"language" : "",
"height" : 360,
"width" : 480
}
]
},
"musicvideoid" : 390,
"tag" : [],
"title" : "A Pair of Brown Eyes",
"lastplayed" : "",
"studio" : [],
"artist" : [
"The Pogues"
],
"userrating" : 0,
"dateadded" : "2016-12-29 16:50:28",
"track" : -1,
"runtime" : 12,
"label" : "A Pair of Brown Eyes",
"fanart" : "",
"thumbnail" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2frum-sodomy--the-lash-52f1dda495de3.jpg/",
"file" : "/Users/martijn/Projects/dummymediafiles/media/musicvideos/The Pogues - A Pair of Brown Eyes (1985).mp4",
"year" : 1985,
"rating" : 0,
"album" : "Rum Sodomy & the Lash",
"resume" : {
"position" : 0,
"total" : 0
},
"art" : {
"poster" : "image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2frum-sodomy--the-lash-52f1dda495de3.jpg/"
},
"director" : [],
"plot" : ""
}
],
"limits" : {
"total" : 439,
"start" : 0,
"end" : 10
}
}
}

View file

@ -0,0 +1,465 @@
{
"id" : "libTVShowSeasons",
"jsonrpc" : "2.0",
"result" : {
"limits" : {
"end" : 21,
"total" : 21,
"start" : 0
},
"seasons" : [
{
"playcount" : 0,
"episode" : 8,
"label" : "Season 1",
"userrating" : 0,
"thumbnail" : "",
"watchedepisodes" : 3,
"seasonid" : 6,
"art" : {
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f260473-1.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f260473-g.jpg/"
},
"fanart" : "",
"showtitle" : "3",
"season" : 1,
"tvshowid" : 2
},
{
"userrating" : 0,
"thumbnail" : "",
"episode" : 49,
"playcount" : 0,
"label" : "Season 1",
"seasonid" : 9,
"watchedepisodes" : 2,
"art" : {
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f146391-2.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/"
},
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/",
"season" : 1,
"showtitle" : "4 Stjerners Middag",
"tvshowid" : 3
},
{
"episode" : 41,
"playcount" : 0,
"label" : "Season 2",
"userrating" : 0,
"thumbnail" : "",
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f146391-2.jpg/"
},
"seasonid" : 10,
"watchedepisodes" : 0,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/",
"tvshowid" : 3,
"season" : 2,
"showtitle" : "4 Stjerners Middag"
},
{
"playcount" : 0,
"episode" : 20,
"label" : "Season 3",
"userrating" : 0,
"thumbnail" : "",
"art" : {
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f146391-2.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/"
},
"watchedepisodes" : 0,
"seasonid" : 11,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/",
"tvshowid" : 3,
"showtitle" : "4 Stjerners Middag",
"season" : 3
},
{
"episode" : 8,
"playcount" : 1,
"label" : "Season 1",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f301824-1-4.jpg/",
"userrating" : 0,
"art" : {
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f301824-g.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f301824-1-4.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f301824-8.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f301824-1-4.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f301824-10.jpg/"
},
"watchedepisodes" : 8,
"seasonid" : 3,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f301824-10.jpg/",
"tvshowid" : 1,
"showtitle" : "11.22.63",
"season" : 1
},
{
"tvshowid" : 137,
"showtitle" : "The A-Team",
"season" : 1,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"art" : {
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-1.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-1.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f77904-g9.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"season.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-1.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/"
},
"watchedepisodes" : 0,
"seasonid" : 553,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-1.jpg/",
"userrating" : 0,
"label" : "Season 1",
"playcount" : 0,
"episode" : 13
},
{
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-2.jpg/",
"playcount" : 0,
"label" : "Season 2",
"episode" : 23,
"seasonid" : 554,
"watchedepisodes" : 0,
"art" : {
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-2.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f77904-g9.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-2.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"season.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-2.jpg/"
},
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"showtitle" : "The A-Team",
"season" : 2,
"tvshowid" : 137
},
{
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"showtitle" : "The A-Team",
"season" : 3,
"tvshowid" : 137,
"episode" : 25,
"playcount" : 0,
"label" : "Season 3",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-3.jpg/",
"userrating" : 0,
"seasonid" : 555,
"watchedepisodes" : 0,
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-3.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"season.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-3.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f77904-g9.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-3.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-3.jpg/"
}
},
{
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"showtitle" : "The A-Team",
"season" : 4,
"tvshowid" : 137,
"episode" : 23,
"playcount" : 0,
"label" : "Season 4",
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-4.jpg/",
"watchedepisodes" : 0,
"seasonid" : 556,
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-4.jpg/",
"season.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-4.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-4.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f77904-g9.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f77904-4.jpg/"
}
},
{
"label" : "Season 5",
"playcount" : 0,
"episode" : 13,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-5.jpg/",
"userrating" : 0,
"seasonid" : 557,
"watchedepisodes" : 0,
"art" : {
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f77904-g9.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-5.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f77904-5.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/"
},
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"season" : 5,
"showtitle" : "The A-Team",
"tvshowid" : 137
},
{
"watchedepisodes" : 9,
"seasonid" : 14,
"art" : {
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-1-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-1-2.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/"
},
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-1-2.jpg/",
"playcount" : 0,
"episode" : 22,
"label" : "Season 1",
"season" : 1,
"showtitle" : "According to Jim",
"tvshowid" : 4,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/"
},
{
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-2-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-2-2.jpg/"
},
"seasonid" : 15,
"watchedepisodes" : 0,
"playcount" : 0,
"label" : "Season 2",
"episode" : 28,
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-2-2.jpg/",
"tvshowid" : 4,
"season" : 2,
"showtitle" : "According to Jim",
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/"
},
{
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-3-2.jpg/",
"playcount" : 0,
"label" : "Season 3",
"episode" : 29,
"watchedepisodes" : 0,
"seasonid" : 16,
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-3-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-3-2.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/"
},
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"showtitle" : "According to Jim",
"season" : 3,
"tvshowid" : 4
},
{
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"season" : 4,
"showtitle" : "According to Jim",
"tvshowid" : 4,
"episode" : 27,
"playcount" : 0,
"label" : "Season 4",
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-4-2.jpg/",
"watchedepisodes" : 0,
"seasonid" : 17,
"art" : {
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-4-2.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-4-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/"
}
},
{
"showtitle" : "According to Jim",
"season" : 5,
"tvshowid" : 4,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"seasonid" : 18,
"watchedepisodes" : 0,
"art" : {
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f3449-5.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f3449-5.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/"
},
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f3449-5.jpg/",
"label" : "Season 5",
"playcount" : 0,
"episode" : 22
},
{
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"season" : 6,
"showtitle" : "According to Jim",
"tvshowid" : 4,
"playcount" : 0,
"episode" : 18,
"label" : "Season 6",
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-6-2.jpg/",
"seasonid" : 19,
"watchedepisodes" : 0,
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-6-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-6-2.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/"
}
},
{
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"tvshowid" : 4,
"season" : 7,
"showtitle" : "According to Jim",
"episode" : 18,
"playcount" : 0,
"label" : "Season 7",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-7-2.jpg/",
"userrating" : 0,
"art" : {
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-7-2.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-7-2.jpg/"
},
"watchedepisodes" : 0,
"seasonid" : 20
},
{
"watchedepisodes" : 0,
"seasonid" : 21,
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-8-2.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-8-2.jpg/"
},
"playcount" : 0,
"episode" : 18,
"label" : "Season 8",
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f75926-8-2.jpg/",
"season" : 8,
"showtitle" : "According to Jim",
"tvshowid" : 4,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/"
},
{
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f252308-4.jpg/",
"tvshowid" : 138,
"season" : 1,
"showtitle" : "The Adventures of Abney & Teal",
"userrating" : 0,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f252308-1.jpg/",
"episode" : 26,
"playcount" : 0,
"label" : "Season 1",
"art" : {
"season.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f252308-1.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f252308-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f252308-1.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f252308-4.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f252308-1.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f252308-1.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f252308-g3.jpg/"
},
"watchedepisodes" : 0,
"seasonid" : 560
},
{
"season" : 2,
"showtitle" : "The Adventures of Abney & Teal",
"tvshowid" : 138,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f252308-4.jpg/",
"seasonid" : 561,
"watchedepisodes" : 0,
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f252308-2.jpg/",
"season.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f252308-2.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f252308-1.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f252308-4.jpg/",
"season.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f252308-2.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f252308-g3.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasonswide%2f252308-2.jpg/"
},
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fseasons%2f252308-2.jpg/",
"userrating" : 0,
"playcount" : 0,
"label" : "Season 2",
"episode" : 26
},
{
"episode" : 1,
"playcount" : 1,
"label" : "Season 3",
"userrating" : 0,
"thumbnail" : "",
"watchedepisodes" : 1,
"seasonid" : 24,
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f104171-1.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f104171-1.jpg/",
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f104171-g.jpg/"
},
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f104171-1.jpg/",
"season" : 3,
"showtitle" : "Air Ways",
"tvshowid" : 5
},
{
"art" : {
"tvshow.banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f259064-g.jpg/",
"tvshow.poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f259064-1.jpg/",
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f259064-1.jpg/"
},
"watchedepisodes" : 0,
"seasonid" : 27,
"thumbnail" : "",
"userrating" : 0,
"episode" : 10,
"playcount" : 0,
"label" : "Season 1",
"tvshowid" : 6,
"showtitle" : "American Colony Meet the Hutterites",
"season" : 1,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f259064-1.jpg/"
},
{
"tvshowid" : 7,
"showtitle" : "Amish: Out Of Order",
"season" : 1,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f258525-1.jpg/",
"art" : {
"tvshow.fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f258525-1.jpg/"
},
"seasonid" : 30,
"watchedepisodes" : 0,
"userrating" : 0,
"thumbnail" : "",
"episode" : 9,
"playcount" : 0,
"label" : "Season 1"
}
]
}
}

View file

@ -0,0 +1,691 @@
{
"id" : "libTVShows",
"jsonrpc" : "2.0",
"result" : {
"tvshows" : [
{
"tvshowid" : 2,
"sorttitle" : "",
"season" : 1,
"lastplayed" : "2017-02-06 16:56:12",
"runtime" : 3600,
"uniqueid" : {
"unknown" : "260473"
},
"imdbnumber" : "260473",
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f260473-1.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f260473-g.jpg/"
},
"originaltitle" : "",
"year" : 2012,
"title" : "3",
"mpaa" : "",
"userrating" : 0,
"studio" : [
"CBS"
],
"rating" : 10,
"fanart" : "",
"tag" : [],
"premiered" : "2012-07-26",
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/3 (2012)/",
"cast" : [],
"genre" : [
"Reality"
],
"episodeguide" : "<episodeguide><url cache=\"260473-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/260473/all/en.zip</url></episodeguide>",
"watchedepisodes" : 3,
"dateadded" : "2016-08-26 09:16:59",
"label" : "3",
"ratings" : {
"default" : {
"votes" : 0,
"rating" : 10,
"default" : true
}
},
"episode" : 8,
"plot" : "Instead of competing against each other, the women searching for love in this relationship series are there to share the experience with one another, offering emotional support during the dating and decision-making process as they whittle down the group of nearly 100 men they start out with and each tries to find a good match. The women -- 29-year-old entrepreneur April Francis, 34-year-old pharmaceutical sales rep Rachel Harley and 24-year-old model Libby Lopez -- bring different backgrounds and experiences to the table, but their common goal unites them as they embark on their journey. Alex Miranda hosts.",
"playcount" : 0,
"votes" : "0",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f260473-1.jpg/"
},
{
"cast" : [],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/4-stjerners middag (2010)/",
"premiered" : "2010-01-25",
"watchedepisodes" : 2,
"episodeguide" : "<episodeguide><url cache=\"146391-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/146391/all/en.zip</url></episodeguide>",
"genre" : [
"Reality"
],
"dateadded" : "2016-08-26 09:16:59",
"votes" : "1",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f146391-2.jpg/",
"ratings" : {
"default" : {
"votes" : 1,
"default" : true,
"rating" : 10
}
},
"episode" : 110,
"playcount" : 0,
"label" : "4 Stjerners Middag",
"plot" : "Danish version of the British \"Come Dine With Me\". Every week four celebrities invites each other home for dinner, one by one. The goal is to make a perfect evening for the three guests, and collect as many points as possible, to be the host of the week. The host picks out the three course dinner and is responsible for buying groceries and preparing the meal.",
"studio" : [
"TVNorge"
],
"rating" : 10,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/",
"tag" : [],
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f146391-2.jpg/",
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f146391-2.jpg/"
},
"imdbnumber" : "146391",
"title" : "4 Stjerners Middag",
"year" : 2010,
"originaltitle" : "",
"userrating" : 0,
"mpaa" : "",
"tvshowid" : 3,
"season" : 3,
"sorttitle" : "",
"lastplayed" : "2017-02-06 17:02:53",
"uniqueid" : {
"unknown" : "146391"
},
"runtime" : 3600
},
{
"title" : "11.22.63",
"year" : 2016,
"originaltitle" : "",
"mpaa" : "TV-MA",
"userrating" : 0,
"imdbnumber" : "301824",
"art" : {
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f301824-10.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f301824-8.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f301824-g.jpg/"
},
"lastplayed" : "2017-02-28 11:14:40",
"runtime" : 3000,
"uniqueid" : {
"unknown" : "301824"
},
"tvshowid" : 1,
"sorttitle" : "",
"season" : 1,
"dateadded" : "2016-08-26 09:17:01",
"ratings" : {
"default" : {
"votes" : 31,
"rating" : 7.69999980926514,
"default" : true
}
},
"episode" : 8,
"label" : "11.22.63",
"playcount" : 1,
"plot" : "A teacher discovers a time portal that leads to October 21st, 1960 and goes on a quest to try and prevent the assassination of John F. Kennedy, which is complicated by the presence of Lee Harvey Oswald and the fact that he's falling in love with the past itself.",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f301824-8.jpg/",
"votes" : "31",
"premiered" : "2016-02-15",
"cast" : [
{
"thumbnail" : "image://nfs%3a%2f%2f192.168.2.3%2f%2fvar%2fdata%2fmedia%2fvideos%2fmovies%2fromance%2f.actors%2fJames_Franco.jpg/",
"order" : 0,
"name" : "James Franco",
"role" : "Jake Epping"
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358603.jpg/",
"order" : 1,
"role" : "Sadie Dunhill",
"name" : "Sarah Gadon"
},
{
"order" : 2,
"thumbnail" : "image://nfs%3a%2f%2f192.168.2.3%2f%2fvar%2fdata%2fmedia%2fvideos%2fmovies%2fdrama%2f.actors%2fChris_Cooper.jpg/",
"role" : "Al Templeton",
"name" : "Chris Cooper"
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f380762.jpg/",
"order" : 3,
"role" : "Harry Dunning",
"name" : "Leon Rippy"
},
{
"name" : "Kevin J. O'Connor",
"role" : "Yellow Card Man",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f370399.jpg/",
"order" : 4
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358610.jpg/",
"order" : 5,
"role" : "Bill Turcotte",
"name" : "George MacKay"
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358609.jpg/",
"order" : 6,
"role" : "Lee Harvey Oswald",
"name" : "Daniel Webber"
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358608.jpg/",
"order" : 7,
"role" : "Johnny Clayton",
"name" : "T.R. Knight"
},
{
"order" : 8,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358607.jpg/",
"role" : "Marquerite Oswald",
"name" : "Cherry Jones"
},
{
"order" : 9,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358606.jpg/",
"name" : "Lucy Fry",
"role" : "Marina Oswald"
},
{
"role" : "Frank Dunning",
"name" : "Josh Duhamel",
"order" : 10,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f358602.jpg/"
},
{
"order" : 22,
"role" : "George de Mohrenschildt",
"name" : "Johny Coyne"
},
{
"thumbnail" : "image://nfs%3a%2f%2f192.168.2.3%2f%2fvar%2fdata%2fmedia%2fvideos%2fmovies%2fdrama%2f.actors%2fNick_Searcy.jpg/",
"order" : 23,
"role" : "Deke Simmons",
"name" : "Nick Searcy"
}
],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/11_22_63 (2016)/",
"genre" : [
"Drama",
"Mini-Series",
"Science-Fiction"
],
"episodeguide" : "<episodeguide><url cache=\"301824-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/301824/all/en.zip</url></episodeguide>",
"watchedepisodes" : 8,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f301824-10.jpg/",
"tag" : [],
"studio" : [
"Hulu"
],
"rating" : 7.69999980926514
},
{
"tvshowid" : 137,
"sorttitle" : "",
"season" : 5,
"lastplayed" : "",
"runtime" : 2700,
"uniqueid" : {
"unknown" : "77904"
},
"imdbnumber" : "77904",
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f77904-g9.jpg/"
},
"year" : 1983,
"originaltitle" : "",
"title" : "The A-Team",
"mpaa" : "TV-PG",
"userrating" : 0,
"studio" : [
"NBC"
],
"rating" : 7.80000019073486,
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f77904-5.jpg/",
"tag" : [],
"premiered" : "1983-01-23",
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/The A-Team (1983)/",
"cast" : [
{
"name" : "George Peppard",
"role" : "Col. John \"Hannibal\" Smith",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f82360.jpg/",
"order" : 0
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59826.jpg/",
"order" : 1,
"name" : "Dirk Benedict",
"role" : "Lt. Templeton \"Faceman\" Peck"
},
{
"name" : "Mr. T",
"role" : "Sgt. Bosco Albert \"B.A.\" Baracus",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59825.jpg/",
"order" : 2
},
{
"order" : 3,
"thumbnail" : "image://nfs%3a%2f%2f192.168.2.3%2f%2fvar%2fdata%2fmedia%2fvideos%2fmovies%2fscifi%2f.actors%2fDwight_Schultz.jpg/",
"role" : "Capt. H.M. \"Howling Mad\" Murdock",
"name" : "Dwight Schultz"
},
{
"order" : 4,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59831.jpg/",
"role" : "Tawnia Baker",
"name" : "Marla Heasley"
},
{
"name" : "Melinda Culea",
"role" : "Amy Amanda 'Triple A' Allen",
"order" : 5,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59827.jpg/"
},
{
"name" : "William Lucking",
"role" : "Col. Lynch",
"order" : 6,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59836.jpg/"
},
{
"role" : "Col. Roderick Decker",
"name" : "Lance LeGault",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59828.jpg/",
"order" : 7
},
{
"name" : "Eddie Velez",
"role" : "Frankie \"Dishpan Man\" Santana",
"order" : 8,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59838.jpg/"
},
{
"role" : "Col. Briggs",
"name" : "Charles Napier",
"thumbnail" : "image://nfs%3a%2f%2f192.168.2.3%2f%2fvar%2fdata%2fmedia%2fvideos%2fmovies%2fcomedy%2f.actors%2fCharles_Napier.jpg/",
"order" : 9
},
{
"role" : "Capt. Crane",
"name" : "Carl Franklin",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f59830.jpg/",
"order" : 10
},
{
"role" : "Gen. Hunt Stockwell",
"name" : "Robert Vaughn",
"order" : 19
}
],
"genre" : [
"Action",
"Adventure"
],
"watchedepisodes" : 0,
"episodeguide" : "<episodeguide><url cache=\"77904-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/77904/all/en.zip</url></episodeguide>",
"dateadded" : "2016-08-26 09:16:58",
"episode" : 97,
"ratings" : {
"default" : {
"votes" : 45,
"rating" : 7.80000019073486,
"default" : true
}
},
"label" : "The A-Team",
"playcount" : 0,
"plot" : "The A-Team is about a group of ex-United States Army Special Forces personnel who work as soldiers of fortune, while on the run from the Army after being branded as war criminals for a crime they didn't commit.",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f77904-3.jpg/",
"votes" : "45"
},
{
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f3449-g.jpg/"
},
"imdbnumber" : "75926",
"userrating" : 0,
"mpaa" : "TV-PG",
"year" : 2001,
"title" : "According to Jim",
"originaltitle" : "",
"sorttitle" : "",
"season" : 8,
"tvshowid" : 4,
"uniqueid" : {
"unknown" : "75926"
},
"runtime" : 1800,
"lastplayed" : "2017-02-28 12:18:10",
"watchedepisodes" : 9,
"episodeguide" : "<episodeguide><url cache=\"75926-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/75926/all/en.zip</url></episodeguide>",
"genre" : [
"Comedy"
],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/According to Jim (2001)/",
"cast" : [
{
"name" : "James Belushi",
"role" : "Jim",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41995.jpg/",
"order" : 0
},
{
"order" : 1,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41994.jpg/",
"role" : "Cheryl",
"name" : "Courtney Thorne-Smith"
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41992.jpg/",
"order" : 2,
"name" : "Kimberly Williams-Paisley",
"role" : "Dana"
},
{
"role" : "Andy",
"name" : "Larry Joe Campbell",
"order" : 3,
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41993.jpg/"
},
{
"role" : "Ruby",
"name" : "Taylor Atelian",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41991.jpg/",
"order" : 4
},
{
"name" : "Conner Rayburn",
"role" : "Kyle",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41990.jpg/",
"order" : 5
},
{
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2factors%2f41989.jpg/",
"order" : 6,
"role" : "Gracie",
"name" : "Billi Bruno"
}
],
"premiered" : "2001-10-03",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f75926-1.jpg/",
"votes" : "34",
"label" : "According to Jim",
"ratings" : {
"default" : {
"votes" : 34,
"default" : true,
"rating" : 8
}
},
"playcount" : 0,
"episode" : 182,
"plot" : "Jim is an abrasive but lovable suburban father. Much like his real life counterpart, Jim's character is noted as a fan of Blues music, as well as the Chicago Blackhawks, Chicago Bulls, Chicago Bears, and the Chicago Cubs. He's married to a gorgeous woman, Cheryl, and raises his five children Ruby, Gracie, Kyle, and twins, Gordan and Jonathan in a big house. Everything is perfect for Jim, if it wasn't for the messy situations he gets himself into and his laziness, which often makes him search for alternative ways of doing things with less effort. Of course, having his wife's siblings hanging out at his house all the time is no help. While Andy might be one of his best friends, Dana often teams up with Cheryl against Jim.",
"dateadded" : "2016-08-26 09:16:59",
"rating" : 8,
"studio" : [
"ABC (US)"
],
"tag" : [],
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f75926-1.jpg/"
},
{
"watchedepisodes" : 0,
"episodeguide" : "<episodeguide><url cache=\"252308-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/252308/all/en.zip</url></episodeguide>",
"genre" : [
"Animation",
"Children"
],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/The Adventures of Abney & Teal (2011)/",
"cast" : [],
"premiered" : "2011-09-26",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f252308-1.jpg/",
"votes" : "1",
"episode" : 52,
"ratings" : {
"default" : {
"votes" : 1,
"rating" : 9,
"default" : true
}
},
"plot" : "Animated adventures of two friends who live on an island in the middle of a lake, in the middle of a park, in the middle of the big city.",
"playcount" : 0,
"label" : "The Adventures of Abney & Teal",
"dateadded" : "2016-08-26 09:16:57",
"rating" : 9,
"studio" : [
"CBeebies"
],
"tag" : [],
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f252308-4.jpg/",
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f252308-1.jpg/",
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f252308-4.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f252308-g3.jpg/"
},
"imdbnumber" : "252308",
"userrating" : 0,
"mpaa" : "TV-Y",
"year" : 2011,
"title" : "The Adventures of Abney & Teal",
"originaltitle" : "",
"season" : 2,
"sorttitle" : "",
"tvshowid" : 138,
"uniqueid" : {
"unknown" : "252308"
},
"runtime" : 660,
"lastplayed" : ""
},
{
"tag" : [],
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f104171-1.jpg/",
"rating" : 3,
"studio" : [
"Seven Network"
],
"episode" : 1,
"ratings" : {
"default" : {
"votes" : 1,
"default" : true,
"rating" : 3
}
},
"label" : "Air Ways",
"plot" : "Follow the ups and downs of travel on Tiger Airways as viewers get an unprecedented look into the day-to-day running of a budget airline in Australia. A cancelled flight causes chaos, staff witness an unexpected proposal, a baggage problem riles a mum's temper, and a sleep-deprived teenager awakens to a rude shock.\r\nNarrated by Corinne Grant",
"playcount" : 1,
"votes" : "1",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f104171-1.jpg/",
"dateadded" : "2016-08-26 09:16:57",
"genre" : [
"Reality"
],
"episodeguide" : "<episodeguide><url cache=\"104171-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/104171/all/en.zip</url></episodeguide>",
"watchedepisodes" : 1,
"premiered" : "2009-07-21",
"cast" : [],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/Airways (2009)/",
"runtime" : 1800,
"uniqueid" : {
"unknown" : "104171"
},
"lastplayed" : "2017-02-06 16:41:56",
"sorttitle" : "",
"season" : 1,
"tvshowid" : 5,
"mpaa" : "TV-PG",
"userrating" : 0,
"originaltitle" : "",
"year" : 2009,
"title" : "Air Ways",
"imdbnumber" : "104171",
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f104171-1.jpg/",
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f104171-1.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f104171-g.jpg/"
}
},
{
"sorttitle" : "",
"season" : 0,
"tvshowid" : 127,
"runtime" : 0,
"uniqueid" : {
"unknown" : "278782"
},
"lastplayed" : "",
"imdbnumber" : "278782",
"art" : {
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f278782-1.jpg/"
},
"mpaa" : "",
"userrating" : 0,
"year" : 1969,
"originaltitle" : "",
"title" : "Al Jazeera Special Series",
"rating" : 10,
"studio" : [],
"tag" : [],
"fanart" : "",
"genre" : [],
"episodeguide" : "<episodeguide><url cache=\"278782-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/278782/all/en.zip</url></episodeguide>",
"watchedepisodes" : 0,
"premiered" : "1969-12-31",
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/Special Series (2016)/",
"cast" : [],
"ratings" : {
"default" : {
"votes" : 1,
"rating" : 10,
"default" : true
}
},
"episode" : 0,
"label" : "Al Jazeera Special Series",
"plot" : "",
"playcount" : 1,
"votes" : "1",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f278782-1.jpg/",
"dateadded" : ""
},
{
"originaltitle" : "",
"year" : 2012,
"title" : "American Colony Meet the Hutterites",
"userrating" : 0,
"mpaa" : "",
"art" : {
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f259064-1.jpg/",
"poster" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f259064-1.jpg/",
"banner" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fgraphical%2f259064-g.jpg/"
},
"imdbnumber" : "259064",
"lastplayed" : "",
"uniqueid" : {
"unknown" : "259064"
},
"runtime" : 1800,
"tvshowid" : 6,
"sorttitle" : "",
"season" : 1,
"dateadded" : "2016-08-26 09:16:57",
"thumbnail" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2fposters%2f259064-1.jpg/",
"votes" : "1",
"plot" : "Meet the Hutterites—a small religious colony in rural Montana who holds desperately to their sacred traditions while fighting the modern temptations of the outside world. King Colony is made up of 59 people and they are almost all related. This family lives together, works together, and worships God together, 7 days a week, 365 days a year, for their entire lives. And, like any family, this one doesnt always agree. Most of the colony is holding tight to the age-old traditions of their ancestors, while others are flirting with modern society. Some feel that bringing modern technology, education, and ideas into the colony will only help it, while others fear that this modern way of thinking threatens their very existence. We follow the men, the women, the young, and the old, as they strive to live as proper Hutterites. Some will succeed, some will fail, and everyone will have a choice to make. This is the very first glimpse into the world of the Hutterites.",
"ratings" : {
"default" : {
"votes" : 1,
"default" : true,
"rating" : 1
}
},
"label" : "American Colony Meet the Hutterites",
"episode" : 10,
"playcount" : 0,
"cast" : [],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/American Colony: Meet the Hutterites (2012)/",
"premiered" : "2012-05-29",
"episodeguide" : "<episodeguide><url cache=\"259064-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/259064/all/en.zip</url></episodeguide>",
"watchedepisodes" : 0,
"genre" : [
"Documentary"
],
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f259064-1.jpg/",
"tag" : [],
"studio" : [
"National Geographic"
],
"rating" : 1
},
{
"runtime" : 3600,
"uniqueid" : {
"unknown" : "258525"
},
"lastplayed" : "",
"season" : 1,
"sorttitle" : "",
"tvshowid" : 7,
"mpaa" : "TV-PG",
"userrating" : 0,
"year" : 2012,
"originaltitle" : "",
"title" : "Amish: Out Of Order",
"imdbnumber" : "258525",
"art" : {
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f258525-1.jpg/"
},
"tag" : [],
"fanart" : "image://http%3a%2f%2fthetvdb.com%2fbanners%2ffanart%2foriginal%2f258525-1.jpg/",
"rating" : 8.5,
"studio" : [
"National Geographic"
],
"label" : "Amish: Out Of Order",
"ratings" : {
"default" : {
"rating" : 8.5,
"default" : true,
"votes" : 0
}
},
"plot" : "It takes a lot to leave the only life youve ever known—for one youve been told will lead you straight to hell. And with little possibility of normal contact with your family ever again, turning your back on the Amish order is an immense undertaking, and a choice thats not made without tremendous consideration. In the new ten-part series Amish: Out of Order, follow the trials and tribulations of individuals who have made the decision to leave the Amish community behind. Due to their religious beliefs, most Amish refuse to be photographed or videotaped—even ex-Amish risk permanent shunning by their family and community for appearing on camera. The ex-Amish in this program accept that risk.",
"playcount" : 0,
"episode" : 9,
"thumbnail" : "",
"votes" : "0",
"dateadded" : "2016-08-26 09:16:59",
"genre" : [
"Documentary",
"Reality"
],
"watchedepisodes" : 0,
"episodeguide" : "<episodeguide><url cache=\"258525-en.xml\">http://thetvdb.com/api/1D62F2F90030C444/series/258525/all/en.zip</url></episodeguide>",
"premiered" : "2012-04-24",
"cast" : [],
"file" : "/Users/martijn/Projects/dummymediafiles/media/tvshows/Amish: Out of Order (2012)/"
}
],
"limits" : {
"start" : 0,
"total" : 177,
"end" : 10
}
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils;
import android.database.Cursor;
public class CursorUtils {
/**
* Converts the current row in cursor to a string with each line
* containing a column name and value pair.
* @param cursor
* @return
*/
public static String cursorToString(Cursor cursor) {
StringBuffer stringBuffer = new StringBuffer();
for (String name : cursor.getColumnNames()) {
int index = cursor.getColumnIndex(name);
stringBuffer.append(name + "=" + cursor.getString(index) + "\n");
}
return stringBuffer.toString();
}
/**
* Moves cursor to first position item is found at column index
* @param cursor
* @param columnIndex
* @param item integer to search for at given column index
* @return true if item found, false otherwise
*/
public static boolean moveCursorToFirstOccurrence(Cursor cursor, int columnIndex, int item) {
if (( cursor == null ) || ( ! cursor.moveToFirst() ))
return false;
do {
if ( cursor.getInt(columnIndex) == item )
return true;
} while (cursor.moveToNext());
return false;
}
/**
* Counts the occurences item is found at given column index
* @param cursor
* @param columnIndex
* @param item integer to search for at given column index
* @return amount of occurences, -1 if an error occured
*/
public static int countOccurences(Cursor cursor, int columnIndex, int item) {
if (( cursor == null ) || ( ! cursor.moveToFirst() ))
return -1;
int count = 0;
do {
if ( cursor.getInt(columnIndex) == item )
count++;
} while (cursor.moveToNext());
return count;
}
}

View file

@ -0,0 +1,181 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.host.HostManager;
import org.xbmc.kore.jsonrpc.ApiException;
import org.xbmc.kore.jsonrpc.ApiList;
import org.xbmc.kore.host.HostConnection;
import org.xbmc.kore.jsonrpc.method.AudioLibrary;
import org.xbmc.kore.jsonrpc.method.VideoLibrary;
import org.xbmc.kore.jsonrpc.type.AudioType;
import org.xbmc.kore.jsonrpc.type.LibraryType;
import org.xbmc.kore.jsonrpc.type.VideoType;
import org.xbmc.kore.provider.MediaContract;
import org.xbmc.kore.service.library.SyncMusic;
import org.xbmc.kore.service.library.SyncMusicVideos;
import org.xbmc.kore.service.library.SyncTVShows;
import org.xbmc.kore.service.library.SyncUtils;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
import java.util.ArrayList;
public class Database {
public static final String TAG = LogUtils.makeLogTag(Database.class);
public static HostInfo fill(HostInfo hostInfo, Context context, ContentResolver contentResolver) throws ApiException, IOException {
SyncMusic syncMusic = new SyncMusic(null);
insertMovies(context, contentResolver, hostInfo.getId());
insertArtists(context, contentResolver, syncMusic, hostInfo.getId());
insertGenres(context, contentResolver, syncMusic, hostInfo.getId());
insertAlbums(context, contentResolver, syncMusic, hostInfo.getId());
insertSongs(context, contentResolver, syncMusic, hostInfo.getId());
SyncTVShows syncTVShows = new SyncTVShows(hostInfo.getId(), null);
insertTVShows(context, contentResolver, syncTVShows);
SyncMusicVideos syncMusicVideos = new SyncMusicVideos(hostInfo.getId(), null);
insertMusicVideos(context, contentResolver, syncMusicVideos);
return hostInfo;
}
public static void flush(ContentResolver contentResolver) {
contentResolver.delete(MediaContract.Hosts.CONTENT_URI, null, null);
}
public static HostInfo addHost(Context context) {
return addHost(context, "127.0.0.1", HostConnection.PROTOCOL_TCP,
HostInfo.DEFAULT_HTTP_PORT, HostInfo.DEFAULT_TCP_PORT, false,
HostInfo.DEFAULT_KODI_VERSION_MAJOR);
}
public static HostInfo addHost(Context context, String hostname, int protocol, int httpPort,
int tcpPort, boolean useEventServer, int kodiMajorVersion) {
return HostManager.getInstance(context).addHost("TestHost", hostname, protocol, httpPort,
tcpPort, null, null, "52:54:00:12:35:02", 9, true,
useEventServer, HostInfo.DEFAULT_EVENT_SERVER_PORT,
kodiMajorVersion,
HostInfo.DEFAULT_KODI_VERSION_MINOR,
HostInfo.DEFAULT_KODI_VERSION_REVISION,
HostInfo.DEFAULT_KODI_VERSION_TAG,
false);
}
private static void insertMovies(Context context, ContentResolver contentResolver, int hostId)
throws ApiException, IOException {
VideoLibrary.GetMovies getMovies = new VideoLibrary.GetMovies();
String result = FileUtils.readFile(context, "Video.Details.Movie.json");
ApiList<VideoType.DetailsMovie> movieList = getMovies.resultFromJson(result);
ContentValues[] movieValuesBatch = new ContentValues[movieList.items.size()];
int castCount = 0;
// Iterate on each movie
for (int i = 0; i < movieList.items.size(); i++) {
VideoType.DetailsMovie movie = movieList.items.get(i);
movieValuesBatch[i] = SyncUtils.contentValuesFromMovie(hostId, movie);
castCount += movie.cast.size();
}
contentResolver.bulkInsert(MediaContract.Movies.CONTENT_URI, movieValuesBatch);
ContentValues[] movieCastValuesBatch = new ContentValues[castCount];
int count = 0;
// Iterate on each movie/cast
for (VideoType.DetailsMovie movie : movieList.items) {
for (VideoType.Cast cast : movie.cast) {
movieCastValuesBatch[count] = SyncUtils.contentValuesFromCast(hostId, cast);
movieCastValuesBatch[count].put(MediaContract.MovieCastColumns.MOVIEID, movie.movieid);
count++;
}
}
contentResolver.bulkInsert(MediaContract.MovieCast.CONTENT_URI, movieCastValuesBatch);
}
private static void insertArtists(Context context, ContentResolver contentResolver, SyncMusic syncMusic, int hostId) throws ApiException, IOException {
AudioLibrary.GetArtists getArtists = new AudioLibrary.GetArtists(false);
String result = FileUtils.readFile(context, "AudioLibrary.GetArtists.json");
ArrayList<AudioType.DetailsArtist> artistList = (ArrayList<AudioType.DetailsArtist>) getArtists.resultFromJson(result).items;
syncMusic.insertArtists(hostId, artistList, contentResolver);
}
private static void insertGenres(Context context, ContentResolver contentResolver, SyncMusic syncMusic, int hostId) throws ApiException, IOException {
AudioLibrary.GetGenres getGenres = new AudioLibrary.GetGenres();
ArrayList<LibraryType.DetailsGenre> genreList =
(ArrayList<LibraryType.DetailsGenre>) getGenres.resultFromJson(FileUtils.readFile(context,
"AudioLibrary.GetGenres.json"));
syncMusic.insertGenresItems(hostId, genreList, contentResolver);
}
private static void insertAlbums(Context context, ContentResolver contentResolver, SyncMusic syncMusic, int hostId) throws ApiException, IOException {
AudioLibrary.GetAlbums getAlbums = new AudioLibrary.GetAlbums();
String result = FileUtils.readFile(context, "AudioLibrary.GetAlbums.json");
ArrayList<AudioType.DetailsAlbum> albumList = (ArrayList<AudioType.DetailsAlbum>) getAlbums.resultFromJson(result).items;
syncMusic.insertAlbumsItems(hostId, albumList, contentResolver);
}
private static void insertSongs(Context context, ContentResolver contentResolver, SyncMusic syncMusic, int hostId) throws ApiException, IOException {
AudioLibrary.GetSongs getSongs = new AudioLibrary.GetSongs();
ArrayList<AudioType.DetailsSong> songList =
(ArrayList<AudioType.DetailsSong>) getSongs.resultFromJson(FileUtils.readFile(context, "AudioLibrary.GetSongs.json")).items;
syncMusic.insertSongsItems(hostId, songList, contentResolver);
}
private static void insertTVShows(Context context, ContentResolver contentResolver, SyncTVShows syncTVShows)
throws ApiException, IOException {
VideoLibrary.GetTVShows getTVShows = new VideoLibrary.GetTVShows();
String result = FileUtils.readFile(context, "VideoLibrary.GetTVShows.json");
ArrayList<VideoType.DetailsTVShow> tvShowList = (ArrayList<VideoType.DetailsTVShow>) getTVShows.resultFromJson(result).items;
syncTVShows.insertTVShows(tvShowList, contentResolver);
for ( VideoType.DetailsTVShow tvShow : tvShowList ) {
VideoLibrary.GetSeasons getSeasons = new VideoLibrary.GetSeasons(tvShow.tvshowid);
result = FileUtils.readFile(context, "VideoLibrary.GetSeasons.json");
ArrayList<VideoType.DetailsSeason> detailsSeasons = (ArrayList<VideoType.DetailsSeason>) getSeasons.resultFromJson(result);
syncTVShows.insertSeason(tvShow.tvshowid, detailsSeasons, contentResolver);
}
VideoLibrary.GetEpisodes getEpisodes = new VideoLibrary.GetEpisodes(0);
result = FileUtils.readFile(context, "VideoLibrary.GetEpisodes.json");
ArrayList<VideoType.DetailsEpisode> detailsEpisodes = (ArrayList<VideoType.DetailsEpisode>) getEpisodes.resultFromJson(result);
syncTVShows.insertEpisodes(detailsEpisodes, contentResolver);
}
private static void insertMusicVideos(Context context, ContentResolver contentResolver, SyncMusicVideos syncMusicVideos)
throws ApiException, IOException {
VideoLibrary.GetMusicVideos getMusicVideos = new VideoLibrary.GetMusicVideos();
String result = FileUtils.readFile(context, "VideoLibrary.GetMusicVideos.json");
ArrayList<VideoType.DetailsMusicVideo> musicVideoList = (ArrayList<VideoType.DetailsMusicVideo>) getMusicVideos.resultFromJson(result);
syncMusicVideos.insertMusicVideos(musicVideoList, contentResolver);
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils;
import android.content.Context;
import java.io.IOException;
import java.io.InputStream;
public class FileUtils {
public static String readFile(Context context, String filename) throws IOException {
InputStream is = context.getAssets().open(filename);
int size = is.available();
byte[] buffer = new byte[size];
is.read(buffer);
is.close();
return new String(buffer, "UTF-8");
}
}

View file

@ -0,0 +1,144 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils;
import android.database.Cursor;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Player;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertTrue;
public class TestUtils {
/**
* Tests if cursor contains all numbers from ids given column index.
* @param cursor
* @param columnIndex
* @param numbers
*/
public static void testCursorContainsNumbers(Cursor cursor, int columnIndex, int... numbers) {
HashMap<Integer, Boolean> idsFound = new HashMap<>();
for(int number : numbers) {
idsFound.put(number, false);
}
assertTrue(cursor.moveToFirst());
do {
idsFound.put(cursor.getInt(columnIndex), true);
} while(cursor.moveToNext());
for(Map.Entry<Integer, Boolean> entry : idsFound.entrySet() ) {
int key = entry.getKey();
assertTrue("Id " + key + " not found", entry.getValue());
}
}
/**
* Tests if cursor contains all numbers from start until end for given column index.
* Including the start and end integers.
* @param columnIndex
* @param cursor
* @param start
* @param end
*/
public static void testCursorContainsRange(Cursor cursor, int columnIndex, int start, int end) {
HashMap<Integer, Boolean> idsFound = new HashMap<>();
for(int i = start; i <= end; i++) {
idsFound.put(i, false);
}
assertTrue(cursor.moveToFirst());
do {
idsFound.put(cursor.getInt(columnIndex), true);
} while(cursor.moveToNext());
for(Map.Entry<Integer, Boolean> entry : idsFound.entrySet() ) {
int key = entry.getKey();
assertTrue("Id " + key + " not found", entry.getValue());
}
}
public static Player.GetItem createMusicItem(int i, int libraryId) {
Player.GetItem getItem = new Player.GetItem();
getItem.addTrack(i);
getItem.addLibraryId(libraryId);
getItem.addAlbum("Album 1");
getItem.addArtist("Artist 1");
getItem.addDisplayartist("Artist 1");
getItem.addAlbumArtist("Album Artist 1");
getItem.addFanart("image://http%3a%2f%2fmedia.theaudiodb.com%2fimages%2fmedia%2fartist%2ffanart%2fxpptss1381301172.jpg/");
getItem.addDuration(240);
getItem.addFile("/Users/martijn/Projects/dummymediafiles/media/music/Artist 1/Album 1/Track " + i + ".mp3");
getItem.addLabel("Label " + i);
getItem.addThumbnail("");
getItem.addTitle("Music "+ i);
getItem.addType(Player.GetItem.TYPE.song);
return getItem;
}
public static Player.GetItem createVideoItem(int i, int libraryId) {
Player.GetItem getItem = new Player.GetItem(0);
getItem.addTrack(i);
getItem.addLibraryId(libraryId);
getItem.addDirector("Director 1");
getItem.addDescription("Description of video " + i);
getItem.addGenre("Drama");
getItem.addFanart("image://http%3a%2f%2fmedia.theaudiodb.com%2fimages%2fmedia%2fartist%2ffanart%2fxpptss1381301172.jpg/");
getItem.addDuration(25);
getItem.addFile("/Users/martijn/Projects/dummymediafiles/media/music/Artist 1/Album 1/Track " + i + ".mp3");
getItem.addLabel("Label " + i);
getItem.addThumbnail("");
getItem.addTitle("Video "+ i);
getItem.addPlot("Plot " + i);
getItem.addYear(2009);
getItem.addType(Player.GetItem.TYPE.movie);
return getItem;
}
public static Player.GetItem createMusicVideoItem(int i, int libraryId) {
Player.GetItem getItem = new Player.GetItem(0);
getItem.addTrack(i);
getItem.addLibraryId(libraryId);
getItem.addType(Player.GetItem.TYPE.musicvideo);
getItem.addAlbum("...Baby One More Time");
getItem.addDirector("Nigel Dick");
getItem.addThumbnail("image://http%3a%2f%2fwww.theaudiodb.com%2fimages%2fmedia%2falbum%2fthumb%2fbaby-one-more-time-4dcff7453745a.jpg/");
getItem.addYear(1999);
getItem.addTitle("(You Drive Me) Crazy");
getItem.addLabel("(You Drive Me) Crazy");
getItem.addDuration(201);
getItem.addGenre("Pop");
getItem.addPremiered("1999-01-01");
return getItem;
}
public static Player.GetItem createPictureItem(int i, int libraryId) {
Player.GetItem getItem = new Player.GetItem(0);
getItem.addLibraryId(libraryId);
getItem.addDescription("Description of picture " + i);
getItem.addFile("/Users/martijn/Projects/dummymediafiles/media/music/Artist 1/Album 1/Track " + i + ".mp3");
getItem.addYear(2008);
getItem.addType(Player.GetItem.TYPE.picture);
return getItem;
}
}

View file

@ -0,0 +1,128 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.testutils.eventserver;
import org.xbmc.kore.eventclient.Packet;
import org.xbmc.kore.utils.LogUtils;
import java.nio.ByteBuffer;
import java.util.Arrays;
/**
* Class that implements a single event packet.
* <pre>
* -----------------------------
* | -H1 Signature ("XBMC") | - 4 x CHAR 4B
* | -H2 Version (eg. 2.0) | - 2 x UNSIGNED CHAR 2B
* | -H3 PacketType | - 1 x UNSIGNED SHORT 2B
* | -H4 Sequence number | - 1 x UNSIGNED LONG 4B
* | -H5 No. of packets in msg | - 1 x UNSIGNED LONG 4B
* | -H6 Payloadsize of packet | - 1 x UNSIGNED SHORT 2B
* | -H7 Client's unique token | - 1 x UNSIGNED LONG 4B
* | -H8 Reserved | - 10 x UNSIGNED CHAR 10B
* |---------------------------|
* | -P1 payload | -
* -----------------------------
* </pre>
*/
abstract public class EventPacket {
private static final String TAG = LogUtils.makeLogTag(EventPacket.class);
//Package types
public final static byte PT_BUTTON = 0x03;
private String signature;
private String version;
private int packetType;
private long sequenceNumber;
private long numberOfPackets;
private int payloadSize;
private long token;
private byte[] payload;
private EventPacket() {}
EventPacket(byte[] packet) {
signature = new String(new byte[] {packet[0], packet[1], packet[2], packet[3]});
version = ((int) packet[4]) + "." + ((int) packet[5]);
packetType = ByteBuffer.wrap(packet, 6, 2).getChar();
sequenceNumber = ByteBuffer.wrap(packet, 8, 4).getInt();
numberOfPackets = ByteBuffer.wrap(packet, 12, 4).getInt();
payloadSize = ByteBuffer.wrap(packet, 16, 2).getChar();
token = ByteBuffer.wrap(packet, 18, 4).getInt();
//Reserved 22 - 32
payload = new byte[payloadSize];
ByteBuffer.wrap(packet, 32, payloadSize).get(payload);
}
@Override
public String toString() {
return signature + ":" +
version + ":" +
packetType + ":" +
sequenceNumber + ":" +
numberOfPackets + ":" +
payloadSize + ":" +
token+ ":" +
payload;
}
public int getPacketType() {
return packetType;
}
public byte[] getPayload() {
return payload;
}
/**
* Returns the packet type from a {@link Packet} as a single byte.
* <br/>
* Note that, although the specification specifies two bytes,
* we only use a single byte in {@link Packet} for the packet types.
* @param packet
* @return second byte of packet type
*/
static public byte getPacketType(byte[] packet) {
return packet[7];
}
/**
* Gets the string from payload terminated by 0x00.
* @param payload byte array holding the characters
* @param offset starting offset of string
* @return string from payload or null if not found
*/
String getStringFromPayload(byte[] payload, int offset) {
int strTerminatorIndex = offset;
for (; strTerminatorIndex < payload.length; strTerminatorIndex++) {
if (payload[strTerminatorIndex] == 0x00)
break;
}
if (strTerminatorIndex == payload.length)
return null;
int stringLength = strTerminatorIndex - offset;
byte[] bytes = new byte[stringLength];
System.arraycopy(payload, offset, bytes, 0, stringLength);
return new String(bytes);
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.testutils.eventserver;
import org.xbmc.kore.utils.LogUtils;
import java.nio.ByteBuffer;
public class EventPacketBUTTON extends EventPacket {
private final static String TAG = LogUtils.makeLogTag(EventPacketBUTTON.class);
private short code;
private String mapName;
private String buttonName;
private boolean repeat;
private boolean down;
private boolean queue;
private short amount;
private byte axis;
private short flags;
public EventPacketBUTTON(byte[] packet) {
super(packet);
byte[] payload = getPayload();
try {
code = ByteBuffer.wrap(payload, 0, 2).getShort();
flags = ByteBuffer.wrap(payload, 2, 2).getShort();
amount = ByteBuffer.wrap(payload, 4, 2).getShort();
mapName = getStringFromPayload(payload, 6);
int nextStringPosition = 6 + mapName.getBytes().length + 1;
buttonName = getStringFromPayload(payload, nextStringPosition);
} catch (ArrayIndexOutOfBoundsException e) {
LogUtils.LOGE(TAG, "Error handling payload " + new String(payload));
}
}
public String getButtonName() {
return buttonName;
}
public String getMapName() {
return mapName;
}
@Override
public String toString() {
return super.toString() +
", code: " + code +
", flags: " + flags +
", amount: " + amount +
", mapName: " + mapName +
", buttonName: " + buttonName;
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.testutils.eventserver;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class MockEventServer extends Thread {
private static final String TAG = LogUtils.makeLogTag(MockEventServer.class);
private int listenPort = 9997;
private boolean keepRunning;
private EventPacket packet;
private DatagramSocket datagramSocket;
public MockEventServer() {
}
public void setListenPort(int portNumber) {
this.listenPort = portNumber;
}
public void run() {
try {
datagramSocket = new DatagramSocket(this.listenPort);
} catch (SocketException e) {
System.out.println("MockEventServer: Failed to open socket: " + e.getMessage());
return;
}
keepRunning = true;
while(keepRunning) {
byte[] buf = new byte[1024];
DatagramPacket datagramPacket = new DatagramPacket(buf, buf.length);
try {
datagramSocket.receive(datagramPacket);
packet = new EventPacketBUTTON(datagramPacket.getData());
} catch (IOException e) {
System.out.println("MockEventServer: error receiving packet: " + e.getMessage());
}
}
}
/**
* Returns the last received packet
* @return
*/
public EventPacket getEventPacket() {
return packet;
}
/**
* Stops the server from listening for new packets
*/
public void shutdown() {
keepRunning = false;
datagramSocket.close();
}
/**
* Resets the state of the event server
*/
public void reset() {
packet = null;
}
}

View file

@ -0,0 +1,224 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver;
import androidx.annotation.NonNull;
import okhttp3.internal.Util;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Collections;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import javax.net.ServerSocketFactory;
public class MockTcpServer {
public static final String TAG = LogUtils.makeLogTag(MockTcpServer.class);
private final ServerSocketFactory serverSocketFactory = ServerSocketFactory.getDefault();
private ServerSocket serverSocket;
private boolean running;
private ExecutorService executor;
private int port = -1;
private InetSocketAddress inetSocketAddress;
private final Set<Socket> openClientSockets =
Collections.newSetFromMap(new ConcurrentHashMap<>());
private final TcpServerConnectionHandler connectionHandler;
// TODO
// Enhance handler to handle multiple connections simultaneously. It can now handle one
// connection at a time, which makes the current setup of the MockTcpServer (with threading)
// overkill.
public interface TcpServerConnectionHandler {
/**
* Processes received input
*/
void processInput(Socket socket);
/**
* Gets the answer for this handler that should be returned to the server after input has been
* processed successfully
* @return answer or null if no answer is available
*/
String getResponse();
}
public MockTcpServer(TcpServerConnectionHandler handler) {
connectionHandler = handler;
}
/**
* Starts the server on localhost on a random free port
*/
public void start() throws IOException {
start(new InetSocketAddress(InetAddress.getByName("localhost"), 0));
}
/**
*
* @param inetSocketAddress set portnumber to 0 to select a random free port
*/
public void start(InetSocketAddress inetSocketAddress) throws IOException {
if (running) throw new IllegalStateException("start() already called");
running = true;
this.inetSocketAddress = inetSocketAddress;
serverSocket = serverSocketFactory.createServerSocket();
// Reuse port if not using a random port
serverSocket.setReuseAddress(inetSocketAddress.getPort() != 0);
serverSocket.bind(inetSocketAddress, 50);
executor = Executors.newCachedThreadPool(Util.threadFactory("MockTcpServer", false));
port = serverSocket.getLocalPort();
LogUtils.LOGD(TAG, "start: server started on " + serverSocket.getInetAddress() + ":" + port);
executor.execute(new Runnable() {
@Override
public void run() {
while (running) {
try {
Socket socket = acceptConnection();
serveConnection(socket);
} catch(IOException e){
//Socket closed
LogUtils.LOGD(TAG, "acceptConnection: " + e.getMessage());
}
}
}
private Socket acceptConnection() throws IOException {
Socket socket = serverSocket.accept();
synchronized (openClientSockets) {
openClientSockets.add(socket);
}
return socket;
}
});
}
public synchronized void shutdown() throws IOException {
if (!running) return;
if (serverSocket == null) throw new IllegalStateException("shutdown() before start()");
running = false;
// Release all sockets and all threads, even if any close fails.
for (Iterator<Socket> s = openClientSockets.iterator(); s.hasNext(); ) {
Socket socket = s.next();
Util.closeQuietly(socket);
s.remove();
}
Util.closeQuietly(serverSocket);
executor.shutdown();
// Await shutdown.
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
throw new IOException("Gave up waiting for executor to shut down");
}
} catch (InterruptedException e) {
LogUtils.LOGD(TAG, "shutdown: " + e.getMessage());
}
}
/**
* Gets the local port of this server socket or -1 if it is not bound
* @return the local port this server is listening on.
*/
public int getPort() {
return port;
}
public String getHostName() {
if ( inetSocketAddress == null )
throw new RuntimeException("Must start server before getting hostname");
return inetSocketAddress.getHostName();
}
private void serveConnection(final Socket socket) {
executor.execute(() -> {
try {
LogUtils.LOGD(TAG, "serveConnection: handling client " + socket.getInetAddress()
+ ":" + socket.getLocalPort());
connectionHandler.processInput(socket);
socket.close();
synchronized (openClientSockets) {
openClientSockets.remove(socket);
}
} catch (IOException e) {
LogUtils.LOGW(TAG, "processing input from " + socket.getInetAddress() + " failed: " + e);
}
});
executor.execute(new Runnable() {
@Override
public void run() {
try {
while ( ! (serverSocket.isClosed() || socket.isClosed()) ) {
sendResponse();
Thread.sleep(100);
}
} catch (IOException e) {
LogUtils.LOGW(TAG, " sending response from " + socket.getInetAddress() + " failed: " + e);
} catch (InterruptedException e) {
LogUtils.LOGW(TAG, " wait interrupted" + e);
}
}
private void sendResponse() throws IOException {
PrintWriter out =
new PrintWriter(socket.getOutputStream(), false);
String answer = connectionHandler.getResponse();
if (answer != null) {
LogUtils.LOGD(TAG, "serveConnection: sendResponse: " +answer);
out.print(answer);
out.flush();
}
}
});
}
@NonNull
@Override
public String toString() {
return "MockTcpServer[" + port + "]";
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.jsonrpc.type.GlobalType;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Application;
import org.xbmc.kore.utils.LogUtils;
import java.util.ArrayList;
import static org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Application.OnVolumeChanged;
/**
* Simulates Application JSON-RPC API
*/
public class ApplicationHandler extends ConnectionHandler {
private static final String TAG = LogUtils.makeLogTag(ApplicationHandler.class);
private boolean muted;
private int volume;
private static final String ID_NODE = "id";
private static final String PARAMS_NODE = "params";
private static final String PROPERTIES_NODE = "properties";
/**
* Sets the muted state and sends a notification
* @param muted
* @param notify true if OnVolumeChanged should be sent, false otherwise
*/
public void setMuted(boolean muted, boolean notify) {
this.muted = muted;
if (notify)
addNotification(new OnVolumeChanged(muted, volume));
}
/**
* Sets the volume and sends a notification
* @param volume
* @param notify true if OnVolumeChanged should be sent, false otherwise
*/
public void setVolume(int volume, boolean notify) {
this.volume = volume;
if (notify)
addNotification(new OnVolumeChanged(muted, volume));
}
public int getVolume() {
return volume;
}
@Override
public void reset() {
super.reset();
this.volume = 0;
this.muted = false;
}
@Override
public String[] getType() {
return new String[]{Application.GetProperties.METHOD_NAME,
Application.SetMute.METHOD_NAME,
Application.SetVolume.METHOD_NAME};
}
@Override
public ArrayList<JsonResponse> createResponse(String method, ObjectNode jsonRequest) {
ArrayList<JsonResponse> jsonResponses = new ArrayList<>();
int methodId = jsonRequest.get(ID_NODE).asInt(-1);
switch (method) {
case Application.GetProperties.METHOD_NAME:
Application.GetProperties response = new Application.GetProperties(methodId);
JsonNode jsonNode = jsonRequest.get(PARAMS_NODE).get(PROPERTIES_NODE);
for (JsonNode node : jsonNode) {
switch(node.asText()) {
case Application.GetProperties.MUTED:
response.addMuteState(muted);
break;
case Application.GetProperties.VOLUME:
response.addVolume(volume);
break;
}
}
jsonResponses.add(response);
break;
case Application.SetMute.METHOD_NAME:
setMuted(!muted, true);
jsonResponses.add(new Application.SetMute(methodId, muted));
break;
case Application.SetVolume.METHOD_NAME:
JsonNode params = jsonRequest.get(PARAMS_NODE);
String value = params.get("volume").asText();
switch (value) {
case GlobalType.IncrementDecrement.INCREMENT:
setVolume(volume + 1, true);
break;
case GlobalType.IncrementDecrement.DECREMENT:
setVolume(volume - 1, true);
break;
default:
setVolume(Integer.parseInt(value), true);
break;
}
jsonResponses.add(new Application.SetVolume(methodId, volume));
break;
default:
LogUtils.LOGD(TAG, "method: " + method + ", not implemented");
}
return jsonResponses;
}
}

View file

@ -0,0 +1,102 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.utils.LogUtils;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.concurrent.TimeoutException;
abstract class ConnectionHandler {
private static final String TAG = LogUtils.makeLogTag(ConnectionHandler.class);
private ArrayList<JsonResponse> notifications = new ArrayList<>();
private HashSet<String> methodsHandled = new HashSet<>();
/**
* Used to determine which methods the handler implements
* @return list of JSON method names
*/
abstract String[] getType();
abstract ArrayList<JsonResponse> createResponse(String method, ObjectNode jsonRequest);
/**
* Used to get any notifications from the handler.
* @return {@link JsonResponse} that should be sent to the client or null if there are no notifications
*/
public ArrayList<JsonResponse> getNotifications() {
ArrayList<JsonResponse> list = new ArrayList<>(notifications);
notifications.clear();
return list;
}
/**
* Returns the response for the requested method.
* @param method requested method
* @param jsonRequest json node containing the original request
* @return {@link JsonResponse} that should be sent to the client
*/
public ArrayList<JsonResponse> getResponse(String method, ObjectNode jsonRequest) {
ArrayList<JsonResponse> responses = createResponse(method, jsonRequest);
methodsHandled.add(method);
return responses;
}
/**
* Sets the state of the handler to its initial state
*/
public void reset() {
methodsHandled.clear();
}
/**
* Waits for given method to be handled by this handler before returning.
* @param method
* @param timeOutMillis
*/
public void waitForMethodHandled(String method, long timeOutMillis) throws TimeoutException {
while ((!methodsHandled.contains(method)) && timeOutMillis > 0) {
try {
Thread.sleep(100);
timeOutMillis -= 100;
} catch (InterruptedException e) {
LogUtils.LOGE(TAG, "Thread.sleep interrupted");
return;
}
}
if (timeOutMillis <= 0)
throw new TimeoutException();
}
/**
* Clears the list of methods handled by the connection handler.
*/
public void clearMethodsHandled() {
methodsHandled.clear();
}
void addNotification(JsonResponse notification) {
notifications.add(notification);
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.jsonrpc.method.Input;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.utils.LogUtils;
import java.util.ArrayList;
/**
* Simulates Input JSON-RPC API
*/
public class InputHandler extends ConnectionHandler {
private static final String TAG = LogUtils.makeLogTag(InputHandler.class);
private static final String ACTION = "action";
private static final String PARAMS_NODE = "params";
private String action;
private String methodName;
@Override
public String[] getType() {
return new String[]{Input.ExecuteAction.METHOD_NAME,
Input.Back.METHOD_NAME,
Input.Up.METHOD_NAME,
Input.Down.METHOD_NAME,
Input.Left.METHOD_NAME,
Input.Right.METHOD_NAME,
Input.Select.METHOD_NAME,
};
}
@Override
public ArrayList<JsonResponse> createResponse(String method, ObjectNode jsonRequest) {
ArrayList<JsonResponse> jsonResponses = new ArrayList<>();
methodName = method;
switch (method) {
case Input.ExecuteAction.METHOD_NAME:
action = jsonRequest.get(PARAMS_NODE).get(ACTION).asText();
break;
case Input.Left.METHOD_NAME:
case Input.Right.METHOD_NAME:
case Input.Up.METHOD_NAME:
case Input.Down.METHOD_NAME:
case Input.Select.METHOD_NAME:
// These inputs do not have an action
break;
default:
LogUtils.LOGD(TAG, "method: " + method + ", not implemented");
}
return jsonResponses;
}
/**
* Returns the last received action
* @return
*/
public String getAction() {
return action;
}
/**
* Returns the last received method name
* @return
*/
public String getMethodName() {
return methodName;
}
}

View file

@ -0,0 +1,257 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.MockTcpServer;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.TimeoutException;
import static org.xbmc.kore.jsonrpc.ApiMethod.ID_NODE;
import static org.xbmc.kore.jsonrpc.ApiMethod.METHOD_NODE;
public class JSONConnectionHandlerManager implements MockTcpServer.TcpServerConnectionHandler {
public static final String TAG = LogUtils.makeLogTag(JSONConnectionHandlerManager.class);
private final HashMap<String, ConnectionHandler> handlersByType = new HashMap<>();
private int amountOfOpenBrackets = 0;
private final ObjectMapper objectMapper = new ObjectMapper();
//HashMap used to prevent adding duplicate responses for the same methodId when invoking
//a handler multiple times.
private final HashMap<String, ArrayList<JsonResponse>> clientResponses = new HashMap<>();
private final HashMap<String, MethodPendingState> methodIdsHandled = new HashMap<>();
private final HashSet<String> notificationsHandled = new HashSet<>();
public void addHandler(ConnectionHandler handler) {
synchronized (handlersByType) {
for (String type : handler.getType()) {
handlersByType.put(type, handler);
}
}
}
@Override
public void processInput(Socket socket) {
StringBuilder stringBuffer = new StringBuilder();
try {
InputStreamReader in = new InputStreamReader(socket.getInputStream());
int i;
while (!socket.isClosed() && (i = in.read()) != -1) {
stringBuffer.append((char) i);
if (isEndOfJSONStringReached((char) i)) {
processJSONInput(stringBuffer.toString());
stringBuffer = new StringBuilder();
}
}
} catch (SocketException e) {
// Socket closed
} catch (IOException e) {
LogUtils.LOGD(TAG, "processInput: error reading from socket: " + socket +
", buffer holds: " + stringBuffer);
LogUtils.LOGE(TAG, e.getMessage());
}
}
/**
* Processes JSON input on individual characters.
* Each iteration should start with an opening accolade { and
* end with a closing accolade to indicate a complete JSON string has been
* fully processed.
* @param c
* @return true if a JSON string was fully processed, false otherwise
*/
private boolean isEndOfJSONStringReached(char c) {
//We simply assume well formed JSON input so it should always start with
//a {. If we need to filter out other input we need to add an additional check
//to detect the first opening accolade.
if ( c == '{' ) {
amountOfOpenBrackets++;
} else if ( c == '}' ) {
amountOfOpenBrackets--;
}
return amountOfOpenBrackets == 0;
}
private void processJSONInput(String input) {
try {
synchronized (clientResponses) {
LogUtils.LOGD(TAG, "processJSONInput: " + input);
JsonParser parser = objectMapper.getFactory().createParser(input);
ObjectNode jsonRequest = objectMapper.readTree(parser);
int methodId = jsonRequest.get(ID_NODE).asInt();
String method = jsonRequest.get(METHOD_NODE).asText();
methodIdsHandled.put(String.valueOf(methodId), new MethodPendingState(method));
if (clientResponses.get(String.valueOf(methodId)) != null)
return;
ConnectionHandler connectionHandler = handlersByType.get(method);
if (connectionHandler != null) {
ArrayList<JsonResponse> responses = connectionHandler.getResponse(method, jsonRequest);
if (responses != null) {
clientResponses.put(String.valueOf(methodId), responses);
}
}
parser.close();
}
} catch (IOException e) {
LogUtils.LOGD(TAG, "processJSONInput: error parsing: " + input);
LogUtils.LOGE(TAG, e.getMessage());
}
}
@Override
public String getResponse() {
StringBuilder stringBuilder = new StringBuilder();
//Handle client responses
synchronized (clientResponses) {
for(Map.Entry<String, ArrayList <JsonResponse>> clientResponse : clientResponses.entrySet()) {
for (JsonResponse jsonResponse : clientResponse.getValue()) {
LogUtils.LOGD(TAG, "sending response: " + jsonResponse.toJsonString());
try {
MethodPendingState methodPending = methodIdsHandled.get(jsonResponse.getId());
methodPending.handled = true;
stringBuilder.append(jsonResponse.toJsonString()).append("\n");
} catch (Exception e) {
LogUtils.LOGD(TAG, "getResponse: Error handling response: " + jsonResponse.toJsonString());
LogUtils.LOGW(TAG, "getResponse: " + e);
}
}
}
clientResponses.clear();
}
synchronized (handlersByType) {
//Build a new set to make sure we only handle each handler once, even if it handles
//multiple types.
HashSet<ConnectionHandler> uniqueHandlers = new HashSet<>(handlersByType.values());
//Handle notifications
for (ConnectionHandler handler : uniqueHandlers) {
ArrayList<JsonResponse> jsonNotifications = handler.getNotifications();
for (JsonResponse jsonResponse : jsonNotifications) {
try {
notificationsHandled.add(jsonResponse.getMethod());
stringBuilder.append(jsonResponse.toJsonString()).append("\n");
} catch (Exception e) {
LogUtils.LOGD(TAG, "getResponse: Error handling notification: " + jsonResponse.toJsonString());
}
}
}
}
if (stringBuilder.length() > 0) {
return stringBuilder.toString();
} else {
return null;
}
}
public void reset() {
synchronized (clientResponses) {
clearNotificationsHandled();
clearMethodsHandled();
clientResponses.clear();
}
}
public void clearMethodsHandled() {
methodIdsHandled.clear();
}
/**
* Waits until at least one response has been processed before returning
*/
public void waitForMethodHandled(String methodName, long timeOutMillis) throws TimeoutException {
while (! isMethodHandled(methodName) && (timeOutMillis > 0)) {
try {
Thread.sleep(500);
timeOutMillis -= 500;
} catch (InterruptedException e) {
LogUtils.LOGW(TAG, "waitForNextResponse got interrupted");
}
}
if (timeOutMillis <= 0)
throw new TimeoutException();
}
public void clearNotificationsHandled() {
notificationsHandled.clear();
}
/**
* Waits until at least one response has been processed before returning
*/
public void waitForNotification(String methodName, long timeOutMillis) throws TimeoutException {
while (! notificationsHandled.contains(methodName) && (timeOutMillis > 0)) {
try {
Thread.sleep(500);
timeOutMillis -= 500;
} catch (InterruptedException e) {
LogUtils.LOGW(TAG, "waitForNextResponse got interrupted");
}
}
if (timeOutMillis <= 0)
throw new TimeoutException();
}
private void addResponse(int id, ArrayList<JsonResponse> jsonResponses) {
}
private boolean isMethodHandled(String methodName) {
for(MethodPendingState methodPending : methodIdsHandled.values()) {
if (methodName.contentEquals(methodPending.name)) {
return methodPending.handled;
}
}
return false;
}
private void setMethodHandled(String methodId) {
}
private static class MethodPendingState {
boolean handled;
String name;
MethodPendingState(String name) {
this.name = name;
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.jsonrpc.method.JSONRPC;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.JSONRPC.Ping;
import java.util.ArrayList;
/**
* Simulates JSON RPC JSON-RPC API
*/
public class JSONRPCHandler extends ConnectionHandler {
@Override
public String[] getType() {
return new String[] {JSONRPC.Ping.METHOD_NAME};
}
@Override
public ArrayList<JsonResponse> createResponse(String method, ObjectNode jsonRequest) {
ArrayList<JsonResponse> jsonResponses = new ArrayList<>();
jsonResponses.add(new Ping(jsonRequest.get("id").asInt()));
return jsonResponses;
}
}

View file

@ -0,0 +1,361 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.jsonrpc.type.GlobalType;
import org.xbmc.kore.jsonrpc.type.PlayerType;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Player;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Playlist;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnAVStart;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnPause;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnPlay;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnPropertyChanged;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnSeek;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnSpeedChanged;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Player.OnStop;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.xbmc.kore.testutils.tcpserver.handlers.PlayerHandler.PLAY_STATE.PAUSED;
import static org.xbmc.kore.testutils.tcpserver.handlers.PlayerHandler.PLAY_STATE.PLAYING;
import static org.xbmc.kore.testutils.tcpserver.handlers.PlayerHandler.PLAY_STATE.STOPPED;
/**
* Simulates Player JSON-RPC API
*/
public class PlayerHandler extends ConnectionHandler {
private static final String TAG = LogUtils.makeLogTag(PlayerHandler.class);
public static String[] repeatModes = {
"off",
"one",
"all"
};
public enum PLAY_STATE {PLAYING, STOPPED, PAUSED}
private PLAY_STATE playState = STOPPED;
private int currentRepeatMode;
private boolean shuffled;
private int elapsedTime;
private Player.GetItem mediaItem;
private List<PlaylistHolder> playlists = new ArrayList<>();
private Playlist.playlistID activePlaylistId = Playlist.playlistID.AUDIO;
private String playerType = PlayerType.GetActivePlayersReturnType.AUDIO;
@Override
public void reset() {
super.reset();
this.shuffled = false;
this.currentRepeatMode = 0;
this.elapsedTime = 0;
this.playState = STOPPED;
playerType = PlayerType.GetActivePlayersReturnType.AUDIO;
playlists = null;
setMediaType(Player.GetItem.TYPE.unknown);
}
@Override
public String[] getType() {
return new String[] {Player.GetActivePlayers.METHOD_NAME,
Player.GetProperties.METHOD_NAME,
Player.GetItem.METHOD_NAME,
Player.SetRepeat.METHOD_NAME,
Player.SetShuffle.METHOD_NAME,
Player.Seek.METHOD_NAME,
Player.PlayPause.METHOD_NAME,
Player.Stop.METHOD_NAME,
Player.Open.METHOD_NAME};
}
@Override
public ArrayList<JsonResponse> createResponse(String method, ObjectNode jsonRequest) {
ArrayList<JsonResponse> jsonResponses = new ArrayList<>();
JsonResponse response = null;
int methodId = jsonRequest.get("id").asInt();
switch (method) {
case Player.GetActivePlayers.METHOD_NAME:
response = handleGetActivePlayers(methodId);
break;
case Player.GetProperties.METHOD_NAME:
response = updatePlayerProperties(createPlayerProperties(methodId));
break;
case Player.GetItem.METHOD_NAME:
response = handleGetItem(methodId);
break;
case Player.SetRepeat.METHOD_NAME:
response = handleSetRepeat(methodId, jsonRequest);
break;
case Player.SetShuffle.METHOD_NAME:
response = handleSetShuffle(methodId, jsonRequest);
break;
case Player.Open.METHOD_NAME:
response = handleOpen(methodId, jsonRequest);
break;
case Player.PlayPause.METHOD_NAME:
response = handlePlayPause(methodId, jsonRequest);
break;
case Player.Seek.METHOD_NAME:
response = handleSeek(methodId, jsonRequest);
break;
case Player.Stop.METHOD_NAME:
handleStop();
break;
default:
LogUtils.LOGD(TAG, "getResponse: unknown method received: "+method);
}
if (response != null)
jsonResponses.add(response);
return jsonResponses;
}
private void setMediaType(Player.GetItem.TYPE mediaType) {
switch (mediaType) {
case movie:
playerType = PlayerType.GetActivePlayersReturnType.VIDEO;
break;
case song:
playerType = PlayerType.GetActivePlayersReturnType.AUDIO;
break;
case unknown:
playerType = PlayerType.GetActivePlayersReturnType.AUDIO;
break;
case musicvideo:
playerType = PlayerType.GetActivePlayersReturnType.VIDEO;
break;
case picture:
playerType = PlayerType.GetActivePlayersReturnType.PICTURE;
break;
case channel:
playerType = PlayerType.GetActivePlayersReturnType.VIDEO;
break;
}
}
/**
* Starts playing current item in the playlist
*/
public void startPlay() {
if (playlists != null && playlists.size() > 0 && activePlaylistId != null) {
mediaItem = playlists.get(activePlaylistId.ordinal()).getCurrentItem();
if (mediaItem != null) {
setMediaType(Player.GetItem.TYPE.valueOf(getMediaItemType()));
addNotification(new OnPlay(mediaItem.getLibraryId(), getMediaItemType(), getPlayerId(), 1));
addNotification(new OnAVStart(mediaItem.getLibraryId(), getMediaItemType(), getPlayerId(), 1));
if (playState == PAUSED) {
addNotification(new OnSpeedChanged(mediaItem.getLibraryId(), getMediaItemType(), getPlayerId(), 1));
}
playState = PLAYING;
}
}
}
public void startPlay(Playlist.playlistID playlistId, int playlistPosition) {
if (playlists == null) return;
activePlaylistId = playlistId;
PlaylistHolder playlistHolder = playlists.get(playlistId.ordinal());
playlistHolder.setPlaylistIndex(playlistPosition);
startPlay();
}
public void stopPlay() {
handleStop();
addNotification(new OnStop(mediaItem.getLibraryId(), getMediaItemType(), false));
this.playState = STOPPED;
mediaItem = null;
}
public void setPlaylists(List<PlaylistHolder> playlists) {
this.playlists = playlists;
}
/**
* Returns the current media item for the media type set through {@link #setMediaType(Player.GetItem.TYPE)}
* @return
*/
public Player.GetItem getMediaItem() {
return mediaItem;
}
/**
* Returns the play position of the current media item
* @return the time elapsed in seconds
*/
public long getTimeElapsed() {
return elapsedTime;
}
public PLAY_STATE getPlayState() {
return playState;
}
private String getMediaItemType() {
return mediaItem.getType();
}
private int getPlayerId() {
switch (playerType) {
case PlayerType.GetActivePlayersReturnType.VIDEO:
return 0;
case PlayerType.GetActivePlayersReturnType.AUDIO:
return 1;
case PlayerType.GetActivePlayersReturnType.PICTURE:
return 2;
default:
return 1;
}
}
private Player.GetProperties updatePlayerProperties(Player.GetProperties playerProperties) {
if (playState == PLAYING)
elapsedTime++;
if ( mediaItem != null ) {
if ( elapsedTime > mediaItem.getDuration() && currentRepeatMode != 0 ) {
elapsedTime = 0;
}
playerProperties.addPercentage((elapsedTime * 100 ) / mediaItem.getDuration());
}
playerProperties.addPosition(elapsedTime);
playerProperties.addTime(0, 0, elapsedTime, 767);
playerProperties.addShuffled(shuffled);
playerProperties.addRepeat(repeatModes[currentRepeatMode]);
playerProperties.addPlaylistId(activePlaylistId.ordinal());
return playerProperties;
}
private Player.GetProperties createPlayerProperties(int id) {
Player.GetProperties properties = new Player.GetProperties(id);
properties.addPlaylistId(activePlaylistId.ordinal());
properties.addRepeat(repeatModes[currentRepeatMode]);
properties.addShuffled(false);
properties.addSpeed(playState == PLAYING ? 1 : 0);
int duration = mediaItem != null ? mediaItem.getDuration() : 0;
int hours = duration / 3600;
int remainder = (duration - (hours * 3600));
int minutes = remainder / 60;
int seconds = remainder - (minutes * 60);
properties.addTotaltime(hours,minutes, seconds,0);
return properties;
}
private JsonResponse handleGetItem(int methodId) {
if (playlists != null && playlists.size() > 0) {
mediaItem = playlists.get(activePlaylistId.ordinal()).getCurrentItem();
}
try {
mediaItem = new Player.GetItem(methodId, mediaItem.toJsonString());
} catch (IOException e) {
LogUtils.LOGE(TAG, "handleGetItem: Error creating new Player.GetItem object");
}
return mediaItem;
}
private JsonResponse handleGetActivePlayers(int methodId) {
if (playState == STOPPED) {
return new Player.GetActivePlayers(methodId);
} else {
return new Player.GetActivePlayers(methodId, getPlayerId(), playerType);
}
}
private JsonResponse handleSetRepeat(int methodId, ObjectNode jsonRequest) {
int playerId = getPlayerIdFromJsonRequest(jsonRequest);
currentRepeatMode = ++currentRepeatMode % 3;
addNotification(new OnPropertyChanged(repeatModes[currentRepeatMode], null, playerId));
return new Player.SetRepeat(methodId, "OK");
}
private JsonResponse handleSetShuffle(int methodId, ObjectNode jsonRequest) {
int playerId = getPlayerIdFromJsonRequest(jsonRequest);
shuffled = !shuffled;
addNotification(new OnPropertyChanged(null, shuffled, playerId));
return new Player.SetShuffle(methodId, "OK");
}
private JsonResponse handleOpen(int methodId, ObjectNode jsonRequest) {
int playlistId = jsonRequest.get("params").get("item").get("playlistid").asInt();
int playlistIndex = jsonRequest.get("params").get("item").get("position").asInt();
startPlay(Playlist.playlistID.values()[playlistId], playlistIndex);
return new Player.Open(methodId);
}
private JsonResponse handlePlayPause(int methodId, ObjectNode jsonRequest) {
playState = playState == PLAYING ? PAUSED : PLAYING; //toggle playstate
int speed = playState == PLAYING ? 1 : 0;
int itemId = mediaItem.getLibraryId();
int playerId = getPlayerIdFromJsonRequest(jsonRequest);
if (playState == PLAYING)
addNotification(new OnPlay(itemId, getMediaItemType(), playerId, speed));
else
addNotification(new OnPause(itemId, getMediaItemType(), playerId, speed));
addNotification(new OnSpeedChanged(itemId, getMediaItemType(), playerId, speed));
return new Player.PlayPause(methodId, speed);
}
private JsonResponse handleSeek(int methodId, ObjectNode jsonRequest) {
if (mediaItem == null)
return new Player.Seek(methodId, 0, 0, 0);
elapsedTime = new GlobalType.Time(jsonRequest.get("params").get("value")).toSeconds();
int playerId = getPlayerIdFromJsonRequest(jsonRequest);
addNotification(new OnSeek(methodId, getMediaItemType(), playerId,
playState == PLAYING ? 1 : 0, 0, elapsedTime));
return new Player.Seek(methodId, (100 * elapsedTime) / (double) mediaItem.getDuration(),
elapsedTime, mediaItem.getDuration());
}
private void handleStop() {
addNotification(new OnStop(mediaItem.getLibraryId(), getMediaItemType(), false));
playState = STOPPED;
}
private int getPlayerIdFromJsonRequest(ObjectNode jsonRequest) {
return jsonRequest.get("params").get("playerid").asInt();
}
}

View file

@ -0,0 +1,133 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Player;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Playlist;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Playlist.OnAdd;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications.Playlist.OnClear;
import org.xbmc.kore.utils.LogUtils;
import java.util.ArrayList;
import java.util.List;
/**
* Simulates Playlist JSON-RPC API
*/
public class PlaylistHandler extends ConnectionHandler {
private static final String TAG = LogUtils.makeLogTag(PlaylistHandler.class);
private static final String ID_NODE = "id";
private static final String PARAMS_NODE = "params";
private static final String PLAYLISTID_NODE = "playlistid";
private ArrayList<PlaylistHolder> playlists = new ArrayList<>();
@Override
public void reset() {
playlists.clear();
}
@Override
public String[] getType() {
return new String[]{Playlist.GetItems.METHOD_NAME, Playlist.GetPlaylists.METHOD_NAME};
}
@Override
public ArrayList<JsonResponse> createResponse(String method, ObjectNode jsonRequest) {
ArrayList<JsonResponse> jsonResponses = new ArrayList<>();
int methodId = jsonRequest.get(ID_NODE).asInt(-1);
switch (method) {
case Playlist.GetItems.METHOD_NAME:
int playlistId = jsonRequest.get(PARAMS_NODE).get(PLAYLISTID_NODE).asInt(-1);
jsonResponses.add(createPlaylist(methodId, playlistId));
break;
case Playlist.GetPlaylists.METHOD_NAME:
jsonResponses.add(new Playlist.GetPlaylists(methodId));
break;
default:
LogUtils.LOGD(TAG, "method: " + method + ", not implemented");
}
return jsonResponses;
}
private Playlist.GetItems createPlaylist(int methodId, int playlistId) {
Playlist.GetItems playlistGetItems = new Playlist.GetItems(methodId);
if (playlists.size() > playlistId) {
for (Player.GetItem getItem : playlists.get(playlistId).getItems()) {
playlistGetItems.addItem(getItem);
}
}
return playlistGetItems;
}
public ArrayList<PlaylistHolder> getPlaylists() {
return playlists;
}
public List<Player.GetItem> getPlaylist(Playlist.playlistID id) {
int playlistId = id.ordinal();
if (playlistId < playlists.size())
return playlists.get(playlistId).getItems();
else
return null;
}
/**
* Clears the playlist and sends the OnClear notification
*/
public void clearPlaylist(Playlist.playlistID id) {
int playlistId = id.ordinal();
if (playlistId >= playlists.size())
return;
OnClear onClearNotification = new OnClear(playlistId);
addNotification(onClearNotification);
playlists.get(playlistId).clear();
}
public void addItemToPlaylist(Playlist.playlistID id, Player.GetItem item, boolean sentNotification) {
int playlistId = id.ordinal();
while (playlists.size() <= playlistId) {
playlists.add(null);
}
PlaylistHolder playlist = playlists.get(playlistId);
if (playlist == null) {
playlist = new PlaylistHolder(playlistId);
playlists.set(playlistId, playlist);
}
playlist.add(item);
if (sentNotification) {
OnAdd onAddNotification = new OnAdd(item.getLibraryId(), item.getType(), playlistId, playlist.getIndexOf(item));
addNotification(onAddNotification);
}
}
}

View file

@ -0,0 +1,55 @@
package org.xbmc.kore.testutils.tcpserver.handlers;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods.Player;
import java.util.ArrayList;
import java.util.List;
public class PlaylistHolder {
private int id;
private List<Player.GetItem> items = new ArrayList<>();
private int currentIndex;
PlaylistHolder(int id) {
this.id = id;
}
public int getId() {
return id;
}
public void clear() {
id = 0;
currentIndex = 0;
items.clear();
}
public void add(Player.GetItem item) {
items.add(item);
}
public List<Player.GetItem> getItems() {
return items;
}
public int getIndexOf(Player.GetItem item) {
return items.indexOf(item);
}
public Player.GetItem getCurrentItem() {
return items.get(currentIndex);
}
public int getPlaylistSize() {
return items.size();
}
public void setPlaylistIndex(int index) {
currentIndex = index;
if (currentIndex < 0)
currentIndex = 0;
else if (currentIndex >= items.size())
currentIndex = getPlaylistSize() - 1;
}
}

View file

@ -0,0 +1,258 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
public abstract class JsonResponse {
private final String TAG = LogUtils.makeLogTag(JsonResponse.class);
private final ObjectNode jsonResponse;
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final String RESULT_NODE = "result";
private static final String PARAMS_NODE = "params";
private static final String METHOD_NODE = "method";
private static final String DATA_NODE = "data";
protected static final String ID_NODE = "id";
private static final String JSONRPC_NODE = "jsonrpc";
public enum TYPE {
OBJECT,
ARRAY
};
public JsonResponse() {
jsonResponse = objectMapper.createObjectNode();
jsonResponse.put(JSONRPC_NODE, "2.0");
}
public JsonResponse(int id) {
this();
jsonResponse.put(ID_NODE, id);
}
public JsonResponse(int id, String jsonString) throws IOException {
jsonResponse = (ObjectNode) objectMapper.readTree(jsonString);
jsonResponse.put(JSONRPC_NODE, "2.0");
jsonResponse.put(ID_NODE, id);
}
protected ObjectNode createObjectNode() {
return objectMapper.createObjectNode();
}
protected ArrayNode createArrayNode() {
return objectMapper.createArrayNode();
}
/**
* Returns the node used to hold the result. First call will create the
* result node for the given type
* @param type that result node should be when first created
* @return result node
*/
protected JsonNode getResultNode(TYPE type) {
JsonNode result;
if(jsonResponse.has(RESULT_NODE)) {
result = jsonResponse.get(RESULT_NODE);
if( result.isArray() && type != TYPE.ARRAY ) {
LogUtils.LOGE(TAG, "requested result node of type Object but response contains result node of type Array");
return null;
}
} else {
switch (type) {
case ARRAY:
result = objectMapper.createArrayNode();
break;
case OBJECT:
default:
result = objectMapper.createObjectNode();
break;
}
jsonResponse.set(RESULT_NODE, result);
}
return result;
}
/**
* Returns the parameters node of the json request object
* Creates one if necessary
* @return Parameters node
*/
private ObjectNode getParametersNode() {
ObjectNode params;
if (jsonResponse.has(PARAMS_NODE)) {
params = (ObjectNode)jsonResponse.get(PARAMS_NODE);
} else {
params = objectMapper.createObjectNode();
jsonResponse.set(PARAMS_NODE, params);
}
return params;
}
private ObjectNode getDataNode() {
ObjectNode data = null;
if (jsonResponse.has(PARAMS_NODE)) {
ObjectNode params = (ObjectNode)jsonResponse.get(PARAMS_NODE);
if(params.has(DATA_NODE)) {
data = (ObjectNode) params.get(DATA_NODE);
}
}
if ( data == null ) {
data = objectMapper.createObjectNode();
ObjectNode params = getParametersNode();
params.set(DATA_NODE, data);
}
return data;
}
protected void setResultToResponse(JsonNode value) {
jsonResponse.set(RESULT_NODE, value);
}
protected void setResultToResponse(boolean value) {
jsonResponse.put(RESULT_NODE, value);
}
protected void setResultToResponse(int value) {
jsonResponse.put(RESULT_NODE, value);
}
protected void setResultToResponse(String value) {
jsonResponse.put(RESULT_NODE, value);
}
protected void setLimits(int start, int end, int total) {
ObjectNode limits = createObjectNode();
limits.put("start", start);
limits.put("end", end);
limits.put("total", total);
((ObjectNode) getResultNode(TYPE.OBJECT)).set("limits", limits);
}
/**
* Adds the value to the array in node with the given key.
* If the array does not exist it will be created
* and added.
* @param node ObjectNode that should contain an entry with key with an array as value
* @param key the key of the item in ObjectNode that should hold the array
* @param value the value to be added to the array
*/
protected void addToArrayNode(ObjectNode node, String key, String value) {
JsonNode jsonNode = node.get(key);
if (jsonNode == null) {
jsonNode = objectMapper.createArrayNode();
node.set(key, jsonNode);
}
if (jsonNode.isArray()) {
((ArrayNode) jsonNode).add(value);
} else {
LogUtils.LOGE(TAG, "JsonNode at key: " + key + " not of type ArrayNode." );
}
}
/**
* Adds the value to the array in node with the given key.
* If the array does not exist it will be created
* and added.
* @param node ObjectNode that should contain an entry with key with an array as value
* @param key the key of the item in ObjectNode that should hold the array
* @param value the value to be added to the array
*/
protected void addToArrayNode(ObjectNode node, String key, ObjectNode value) {
JsonNode jsonNode = node.get(key);
if (jsonNode == null) {
jsonNode = objectMapper.createArrayNode();
node.set(key, jsonNode);
}
if (jsonNode.isArray()) {
((ArrayNode) jsonNode).add(value);
} else {
LogUtils.LOGE(TAG, "JsonNode at key: " + key + " not of type ArrayNode." );
}
}
protected void addToArrayNode(ObjectNode node, String key, JsonNode value) {
JsonNode jsonNode = node.get(key);
if (jsonNode == null) {
jsonNode = objectMapper.createArrayNode();
node.set(key, jsonNode);
}
if (jsonNode.isArray()) {
((ArrayNode) jsonNode).add(value);
} else {
LogUtils.LOGE(TAG, "JsonNode at key: " + key + " not of type ArrayNode." );
}
}
protected void addDataToResponse(String parameter, boolean value) {
getDataNode().put(parameter, value);
}
protected void addDataToResponse(String parameter, int value) {
getDataNode().put(parameter, value);
}
protected void addDataToResponse(String parameter, ObjectNode node) {
getDataNode().set(parameter, node);
}
protected void addParameterToResponse(String parameter, String value) {
getParametersNode().put(parameter, value);
}
protected void addMethodToResponse(String method) {
jsonResponse.put(METHOD_NODE, method);
}
public ObjectNode getResponseNode() {
return jsonResponse;
}
public JsonNode getResultNode() {
return jsonResponse.get(RESULT_NODE);
}
public String getId() {
return jsonResponse.get(ID_NODE).asText();
}
public String getMethod() {
return jsonResponse.get(METHOD_NODE).asText();
}
public String toJsonString() {
return jsonResponse.toString();
}
}

View file

@ -0,0 +1,39 @@
package org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.jsonrpc.type.GlobalType;
import org.xbmc.kore.utils.LogUtils;
public class JsonUtils {
/**
* Fills objectNode with time values
* @param objectNode
* @param timeSec
* @return objectNode for chaining
*/
public static ObjectNode createTimeNode(ObjectNode objectNode, long timeSec) {
int hours = (int) timeSec / 3600;
int minutes = (int) ( timeSec / 60 ) % 60;
int seconds = (int) timeSec % 60 ;
return createTimeNode(objectNode, hours, minutes, seconds, 0);
}
/**
* Fills objectNode with time values
* @param objectNode
* @param hours
* @param minutes
* @param seconds
* @param milliseconds
* @return objectNode for chaining
*/
public static ObjectNode createTimeNode(ObjectNode objectNode, int hours, int minutes, int seconds, int milliseconds) {
objectNode.put(GlobalType.Time.HOURS, hours);
objectNode.put(GlobalType.Time.MINUTES, minutes);
objectNode.put(GlobalType.Time.SECONDS, seconds);
objectNode.put(GlobalType.Time.MILLISECONDS, milliseconds);
return objectNode;
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.nodes;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
public class AudioDetailsNode extends JsonResponse {
private AudioDetailsNode() {};
public AudioDetailsNode(int channels, String codec, String language) {
ObjectNode node = (ObjectNode) getResultNode(TYPE.OBJECT);
node.put("channels", channels);
node.put("codec", codec);
node.put("language", language);
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.nodes;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
public class SubtitleDetailsNode extends JsonResponse {
private SubtitleDetailsNode() {};
public SubtitleDetailsNode(String language) {
ObjectNode node = (ObjectNode) getResultNode(TYPE.OBJECT);
node.put("language", language);
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.nodes;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
public class VideoDetailsNode extends JsonResponse {
private VideoDetailsNode() {};
public VideoDetailsNode(int width, int height, float aspect, String code, int duration) {
ObjectNode node = (ObjectNode) getResultNode(TYPE.OBJECT);
node.put("width", width);
node.put("height", height);
node.put("aspect", aspect);
node.put("code", code);
node.put("duration", duration);
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright 2017 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import java.io.IOException;
/**
* Serverside JSON RPC responses in Addons.*
*/
public class Addons {
/**
* JSON response for Addons.GetAddons request
*
* @return JSON string
*/
public static class GetAddons extends JsonResponse {
public final static String METHOD_NAME = "Addons.GetAddons";
public GetAddons(int id, String jsonString) throws IOException {
super(id, jsonString);
}
}
}

View file

@ -0,0 +1,99 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
/**
* Serverside JSON RPC responses in Application.*
*/
public class Application {
/**
* JSON response for Application.SetMute request
*
* Example:
* Query: {"jsonrpc":"2.0","method":"Application.SetMute","id":1,"params":{"mute":"toggle"}}
* Answer: muted: {"id":1,"jsonrpc":"2.0","result":false}
* not muted: {"id":1,"jsonrpc":"2.0","result":true}
*
* @return JSON string
*/
public static class SetMute extends JsonResponse {
public final static String METHOD_NAME = "Application.SetMute";
public SetMute(int id, boolean muteState) {
super(id);
setResultToResponse(muteState);
}
}
/**
* JSON response for GetProperties requests
*
* Example:
* Query: {"jsonrpc":"2.0","method":"Application.GetProperties","id":1,"params":{"properties":["muted"]}}
* Answer: {"id":1,"jsonrpc":"2.0","result":{"muted":true}}
*
* @return JSON string
*/
public static class GetProperties extends JsonResponse {
public final static String METHOD_NAME = "Application.GetProperties";
public final static String MUTED = "muted";
public final static String VOLUME = "volume";
private ObjectNode node = null;
public GetProperties(int id) {
super(id);
}
public void addMuteState(boolean muteState) {
node = (ObjectNode) getResultNode(TYPE.OBJECT);
node.put(MUTED, muteState);
}
public void addVolume(int volume) {
node = (ObjectNode) getResultNode(TYPE.OBJECT);
node.put(VOLUME, volume);
}
}
/**
* JSON response for Application.SetVolume request
*
* Examples:
* Query: {"jsonrpc":"2.0","method":"Application.SetVolume","id":1,"params":{"volume":100}}
* Answer: {"id":1,"jsonrpc":"2.0","result":100}
*
* Query: {"jsonrpc":"2.0","method":"Application.SetVolume","id":1,"params":{"volume":"decrement"}}
* Answer: {"id":1,"jsonrpc":"2.0","result":99}
*
* @return JSON string
*/
public static class SetVolume extends JsonResponse {
public final static String METHOD_NAME = "Application.SetVolume";
public SetVolume(int id, int volume) {
super(id);
setResultToResponse(volume);
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
/**
* Serverside JSON RPC responses in Application.*
*/
public class JSONRPC {
public static class Ping extends JsonResponse {
public final static String METHOD_NAME = "JSONRPC.Ping";
public Ping(int id) {
super(id);
setResultToResponse("pong");
}
}
}

View file

@ -0,0 +1,526 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.jsonrpc.type.PlayerType;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonUtils;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.nodes.AudioDetailsNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.nodes.SubtitleDetailsNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.nodes.VideoDetailsNode;
import org.xbmc.kore.utils.LogUtils;
import java.io.IOException;
/**
* Serverside JSON RPC responses in Methods.Player.*
*/
public class Player {
/**
* JSON response for Player.Open request
*
* Example:
* Query: {"jsonrpc":"2.0","method":"Player.Open","id":77,"params":{"item":{"playlistid":0,"position":2}}}
* Answer: {"id":77,"jsonrpc":"2.0","result":"OK"}
*
* @return JSON string
*/
public static class Open extends JsonResponse {
public final static String METHOD_NAME = "Player.Open";
public Open(int methodId) {
super(methodId);
setResultToResponse("OK");
}
}
/**
* JSON response for Player.Seek request
*
* Example:
* Query: {"jsonrpc":"2.0","method":"Player.Seek","id":41,"params":{"playerid":0,"value":{"hours":0,"milliseconds":0,"minutes":0,"seconds":2}}}
* Answer: {"id":41,"jsonrpc":"2.0","result":{"percentage":16.570009231567382812,"time":{"hours":0,"milliseconds":0,"minutes":0,"seconds":2},"totaltime":{"hours":0,"milliseconds":70,"minutes":0,"seconds":12}}}
*
* @return JSON string
*/
public static class Seek extends JsonResponse {
public final static String METHOD_NAME = "Player.Seek";
public Seek(int methodId, double percentage, long timeSec, long totalTime) {
super(methodId);
ObjectNode resultNode = (ObjectNode) getResultNode(TYPE.OBJECT);
resultNode.put("percentage", percentage);
resultNode.set("time", JsonUtils.createTimeNode(createObjectNode(), timeSec));
resultNode.set("totalTime", JsonUtils.createTimeNode(createObjectNode(), totalTime));
}
}
public static class SetShuffle extends JsonResponse {
public final static String METHOD_NAME = "Player.SetShuffle";
public SetShuffle(int methodId, String result) {
super(methodId);
setResultToResponse(result);
}
}
public static class SetRepeat extends JsonResponse {
public final static String METHOD_NAME = "Player.SetRepeat";
public SetRepeat(int methodId, String result) {
super(methodId);
setResultToResponse(result);
}
}
public static class PlayPause extends JsonResponse {
public final static String METHOD_NAME = "Player.PlayPause";
public PlayPause(int methodId, int speed) {
super(methodId);
((ObjectNode) getResultNode(TYPE.OBJECT)).put("speed", speed);
}
}
public static class Stop extends JsonResponse {
public final static String METHOD_NAME = "Player.Stop";
}
public static class GetActivePlayers extends JsonResponse {
public final static String METHOD_NAME = "Player.GetActivePlayers";
public GetActivePlayers(int methodId) {
super(methodId);
getResultNode(TYPE.ARRAY);
}
public GetActivePlayers(int methodId, int playerId, String type) {
super(methodId);
ObjectNode objectNode = createObjectNode();
objectNode.put("playerid", playerId);
objectNode.put("type", type);
((ArrayNode) getResultNode(TYPE.ARRAY)).add(objectNode);
}
}
public static class GetProperties extends JsonResponse {
public final static String METHOD_NAME = "Player.GetProperties";
final static String SPEED = PlayerType.PropertyName.SPEED;
final static String PERCENTAGE = PlayerType.PropertyName.PERCENTAGE;
final static String POSITION = PlayerType.PropertyName.POSITION;
final static String TIME = PlayerType.PropertyName.TIME;
final static String TOTALTIME = PlayerType.PropertyName.TOTALTIME;
final static String REPEAT = PlayerType.PropertyName.REPEAT;
final static String SHUFFLED = PlayerType.PropertyName.SHUFFLED;
final static String CURRENTAUDIOSTREAM = PlayerType.PropertyName.CURRENTAUDIOSTREAM;
final static String CURRENTSUBTITLE = PlayerType.PropertyName.CURRENTSUBTITLE;
final static String AUDIOSTREAMS = PlayerType.PropertyName.AUDIOSTREAMS;
final static String SUBTITLES = PlayerType.PropertyName.SUBTITLES;
final static String PLAYLISTID = PlayerType.PropertyName.PLAYLISTID;
public GetProperties(int methodId) {
super(methodId);
}
public void addSpeed(int value) {
((ObjectNode) getResultNode(TYPE.OBJECT)).put(SPEED, value);
}
public void addPercentage(int value) {
((ObjectNode) getResultNode(TYPE.OBJECT)).put(PERCENTAGE, value);
}
public void addPosition(int value) {
((ObjectNode) getResultNode(TYPE.OBJECT)).put(POSITION, value);
}
public void addTime(int hours, int minutes, int seconds, int milliseconds) {
ObjectNode timeNode = JsonUtils.createTimeNode(createObjectNode(), hours, minutes, seconds, milliseconds);
((ObjectNode) getResultNode(TYPE.OBJECT)).putObject(TIME).setAll(timeNode);
}
public void addTotaltime(int hours, int minutes, int seconds, int milliseconds) {
ObjectNode timeNode = JsonUtils.createTimeNode(createObjectNode(), hours, minutes, seconds, milliseconds);
((ObjectNode) getResultNode(TYPE.OBJECT)).putObject(TOTALTIME).setAll(timeNode);
}
public void addRepeat(String value) {
((ObjectNode) getResultNode(TYPE.OBJECT)).put(REPEAT, value);
}
public void addShuffled(boolean value) {
((ObjectNode) getResultNode(TYPE.OBJECT)).put(SHUFFLED, value);
}
public void addCurrentAudioStream(int channels, String codec, int bitrate) {
ObjectNode objectNode = createAudioStreamNode(channels, codec, bitrate);
((ObjectNode) getResultNode(TYPE.OBJECT)).putObject(CURRENTAUDIOSTREAM).setAll(objectNode);
}
public void addCurrentSubtitle(int index, String language, String name) {
ObjectNode objectNode = createSubtitleNode(index, language, name);
((ObjectNode) getResultNode(TYPE.OBJECT)).putObject(CURRENTSUBTITLE).setAll(objectNode);
}
public void addAudioStream(int channels, String codec, int bitrate) {
ObjectNode objectNode = createAudioStreamNode(channels, codec, bitrate);
addObjectToArray(AUDIOSTREAMS, objectNode);
}
public void addSubtitle(int index, String language, String name) {
ObjectNode objectNode = createSubtitleNode(index, language, name);
addObjectToArray(SUBTITLES, objectNode);
}
public void addPlaylistId(int value) {
((ObjectNode) getResultNode(TYPE.OBJECT)).put(PLAYLISTID, value);
}
private ObjectNode createAudioStreamNode(int channels, String codec, int bitrate) {
ObjectNode audioNode = createObjectNode();
audioNode.put("channels", channels);
audioNode.put("codec", codec);
audioNode.put("bitrate", bitrate);
return audioNode;
}
private ObjectNode createSubtitleNode(int index, String language, String name) {
ObjectNode subtitleNode = createObjectNode();
subtitleNode.put("index", index);
subtitleNode.put("language", language);
subtitleNode.put("name", name);
return subtitleNode;
}
private void addObjectToArray(String key, ObjectNode objectNode) {
ObjectNode resultNode = (ObjectNode) getResultNode(TYPE.OBJECT);
JsonNode jsonNode = resultNode.get(key);
if(jsonNode == null) {
ArrayNode arrayNode = createArrayNode().add(objectNode);
resultNode.set(key, arrayNode);
} else if(jsonNode.isArray()) {
((ArrayNode) jsonNode).add(objectNode);
} else {
LogUtils.LOGW("Player", "JsonNode at " + key + " is not of type ArrayNode");
}
}
}
/**
* Example:
* query: {"jsonrpc":"2.0","method":"Player.GetItem","id":4119,"params":{"playerid":0,"properties":["art","artist","albumartist","album","cast","director","displayartist","duration","episode","fanart","file","firstaired","genre","imdbnumber","plot","premiered","rating","resume","runtime","season","showtitle","streamdetails","studio","tagline","thumbnail","title","top250","track","votes","writer","year","description"]}}
* answer: {"id":4119,"jsonrpc":"2.0","result":{"item":{"album":"My Time Is the Right Time","albumartist":["Alton Ellis"],"art":{"artist.fanart":"image://http%3a%2f%2fmedia.theaudiodb.com%2fimages%2fmedia%2fartist%2ffanart%2fxpptss1381301172.jpg/"},"artist":["Alton Ellis"],"displayartist":"Alton Ellis","duration":5,"fanart":"image://http%3a%2f%2fmedia.theaudiodb.com%2fimages%2fmedia%2fartist%2ffanart%2fxpptss1381301172.jpg/","file":"/Users/martijn/Projects/dummymediafiles/media/music/Alton Ellis/My Time Is The Right Time/06-Rock Steady.mp3","genre":["Reggae"],"id":14769,"label":"Rock Steady","rating":0,"thumbnail":"","title":"Rock Steady","track":6,"type":"song","votes":0,"year":2000}}}
*/
public static class GetItem extends JsonResponse {
public final static String METHOD_NAME = "Player.GetItem";
final static String ITEM = "item";
final static String TYPE = "type";
final static String ART = "art";
final static String ARTIST = "artist";
final static String ALBUMARTIST = "albumartist";
final static String ALBUM = "album";
final static String CAST = "cast";
final static String DIRECTOR = "director";
final static String DISPLAYARTIST = "displayartist";
final static String DURATION = "duration";
final static String EPISODE = "episode";
final static String FANART = "fanart";
final static String FILE = "file";
final static String FIRSTAIRED = "firstaired";
final static String GENRE = "genre";
final static String IMDBNUMBER = "imdbnumber";
final static String PLOT = "plot";
final static String PREMIERED = "premiered";
final static String RATING = "rating";
final static String RESUME = "resume";
final static String RUNTIME = "runtime";
final static String SEASON = "season";
final static String SHOWTITLE = "showtitle";
final static String STREAMDETAILS = "streamdetails";
final static String STUDIO = "studio";
final static String TAGLINE = "tagline";
final static String THUMBNAIL = "thumbnail";
final static String TITLE = "title";
final static String TOP250 = "top250";
final static String TRACK = "track";
final static String VOTES = "votes";
final static String WRITER = "writer";
final static String YEAR = "year";
final static String DESCRIPTION = "description";
final static String LABEL = "label";
public enum TYPE { unknown,
movie,
episode,
musicvideo,
song,
picture,
channel
}
private ObjectNode itemNode;
public GetItem() {
super();
setupItemNode();
}
public GetItem(int methodId) {
super(methodId);
setupItemNode();
}
public GetItem(int methodId, String jsonString) throws IOException {
super(methodId, jsonString);
ObjectNode resultNode = ((ObjectNode) getResultNode(JsonResponse.TYPE.OBJECT));
if (resultNode.has(ITEM)) {
itemNode = (ObjectNode) resultNode.get(ITEM);
} else {
setupItemNode();
}
}
private void setupItemNode() {
ObjectNode resultNode = ((ObjectNode) getResultNode(JsonResponse.TYPE.OBJECT));
itemNode = createObjectNode();
resultNode.set(ITEM, itemNode);
}
public void addLibraryId(int id) {
itemNode.put(ID_NODE, id);
}
/**
* @return library identifier or -1 if not set
*/
public int getLibraryId() {
JsonNode idNode = itemNode.get(ID_NODE);
if (idNode != null)
return idNode.asInt();
else
return -1;
}
public void addType(TYPE type) {
itemNode.put(TYPE, type.name());
}
public String getType() {
return itemNode.get(TYPE).textValue();
}
public void addArt(String banner, String poster, String fanart, String thumbnail) {
ObjectNode objectNode = createArtNode(banner, poster, fanart, thumbnail);
itemNode.putObject(ART).setAll(objectNode);
}
public void addArtist(String artist) {
addToArrayNode(itemNode, ARTIST, artist);
}
public void addAlbumArtist(String artist) {
addToArrayNode(itemNode, ALBUMARTIST, artist);
}
public void addAlbum(String album) {
itemNode.put(ALBUM, album);
}
public void addCast(String thumbnail, String name, String role) {
addToArrayNode(itemNode, CAST, createCastNode(thumbnail, name, role));
}
public void addDirector(String director) {
addToArrayNode(itemNode, DIRECTOR, director);
}
public void addDisplayartist(String displayartist) {
itemNode.put(DISPLAYARTIST, displayartist);
}
public void addDuration(int duration) {
itemNode.put(DURATION, duration);
}
public int getDuration() {
return itemNode.get(DURATION).asInt();
}
public void addEpisode(int episode) {
itemNode.put(EPISODE, episode);
}
public void addFanart(String fanart) {
itemNode.put(FANART, fanart);
}
public void addFile(String file) {
itemNode.put(FILE, file);
}
public void addFirstaired(String firstaired) {
itemNode.put(FIRSTAIRED, firstaired);
}
public void addGenre(String genre) {
itemNode.put(GENRE, genre);
}
public void addImdbnumber(String imdbnumber) {
itemNode.put(IMDBNUMBER, imdbnumber);
}
public void addPlot(String plot) {
itemNode.put(PLOT, plot);
}
public void addPremiered(String premiered) {
itemNode.put(PREMIERED, premiered);
}
public void addRating(int rating) {
itemNode.put(RATING, rating);
}
public void addResume(int position, int total) {
itemNode.putObject(RESUME).setAll(createResumeNode(position, total));
}
public int getRuntime() {
return itemNode.get(RUNTIME).asInt();
}
public void addRuntime(int runtime) {
itemNode.put(RUNTIME, runtime);
}
public void addSeason(int season) {
itemNode.put(SEASON, season);
}
public void addShowtitle(String showtitle) {
itemNode.put(SHOWTITLE, showtitle);
}
public void addStreamdetails(AudioDetailsNode audioDetailsNode,
VideoDetailsNode videoDetailsNode,
SubtitleDetailsNode subtitleDetailsNode) {
ObjectNode objectNode = createObjectNode();
objectNode.putObject("audio").setAll(audioDetailsNode.getResponseNode());
objectNode.putObject("video").setAll(videoDetailsNode.getResponseNode());
objectNode.putObject("subtitle").setAll(subtitleDetailsNode.getResponseNode());
itemNode.set(STREAMDETAILS, objectNode);
}
public void addStudio(String studio) {
addToArrayNode(itemNode, STUDIO, studio);
}
public void addTagline(String tagline) {
itemNode.put(TAGLINE, tagline);
}
public void addThumbnail(String thumbnail) {
itemNode.put(THUMBNAIL, thumbnail);
}
public void addTitle(String title) {
itemNode.put(TITLE, title);
}
public String getTitle() {
JsonNode jsonNode = itemNode.get(TITLE);
if (jsonNode != null)
return jsonNode.asText();
else
return null;
}
public void addTop250(int top250) {
itemNode.put(TOP250, top250);
}
public void addTrack(int track) {
itemNode.put(TRACK, track);
}
public void addVotes(String votes) {
itemNode.put(VOTES, votes);
}
public void addWriter(String writer) {
addToArrayNode(itemNode, WRITER, writer);
}
public void addYear(int year) {
itemNode.put(YEAR, year);
}
public void addDescription(String description) {
itemNode.put(DESCRIPTION, description);
}
public void addLabel(String label) {
itemNode.put(LABEL, label);
}
private ObjectNode createArtNode(String banner,
String poster,
String fanart,
String thumbnail) {
ObjectNode objectNode = createObjectNode();
objectNode.put("poster", poster);
objectNode.put("fanart", fanart);
objectNode.put("thumbnail", thumbnail);
objectNode.put("banner", banner);
return objectNode;
}
private ObjectNode createArtworkNode(String banner, String poster, String fanart, String thumbnail) {
ObjectNode objectNode = createObjectNode();
objectNode.put("poster", poster);
objectNode.put("fanart", fanart);
objectNode.put("thumbnail", thumbnail);
return objectNode;
}
private ObjectNode createCastNode(String thumbnail, String name, String role) {
ObjectNode objectNode = createObjectNode();
objectNode.put("thumbnail", thumbnail);
objectNode.put("name", name);
objectNode.put("role", role);
return objectNode;
}
private ObjectNode createResumeNode(int position, int total) {
ObjectNode objectNode = createObjectNode();
objectNode.put("position", position);
objectNode.put("total", total);
return objectNode;
}
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.methods;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
/**
* Serverside JSON RPC responses in Playlist.*
*/
public class Playlist {
public enum playlistID {
AUDIO, VIDEO, PICTURE
}
/**
* JSON response for Playlist.GetItems request
*
* * Example:
* Query: {"jsonrpc":"2.0","method":"Playlist.GetItems","id":48,"params":
* {"playlistid":0,"properties":["art","artist","albumartist","album",
* "displayartist","episode","fanart","file","season",
* "showtitle","studio","tagline","thumbnail","title",
* "track","duration","runtime"]
* }
* }
* Answer: {"id":1,"jsonrpc":"2.0","result":{"items":
* [
* {"album":"My Time Is the Right Time",
* "albumartist":[],
* "art":{"artist.fanart":"image://http%3a%2f%2fmedia.theaudiodb.com%2fimages%2fmedia%2fartist%2ffanart%2fxpptss1381301172.jpg/"},
* "artist":["Alton Ellis"],
* "displayartist":"Alton Ellis",
* "duration":5,
* "fanart":"image://http%3a%2f%2fmedia.theaudiodb.com%2fimages%2fmedia%2fartist%2ffanart%2fxpptss1381301172.jpg/",
* "file":"/Users/martijn/Projects/dummymediafiles/media/music/Alton Ellis/My Time Is The Right Time/17-Black Man's Word.mp3",
* "id":41,
* "label":"Black Man's Word",
* "thumbnail":"",
* "title":"Black Man's Word",
* "track":17,
* "type":"song"}
* ],
* "limits":{"end":1,"start":0,"total":1}}}
*
* Playlist empty answer : {"id":48,"jsonrpc":"2.0","result":{"limits":{"end":0,"start":0,"total":0}}}
*
* @return JSON string
*/
public static class GetItems extends JsonResponse {
public final static String METHOD_NAME = "Playlist.GetItems";
int limitsEnd;
public GetItems(int id) {
super(id);
}
@Override
public String toJsonString() {
setLimits(0, limitsEnd, limitsEnd);
return super.toJsonString();
}
public void addItem(Player.GetItem playerItem) {
ObjectNode resultNode = (ObjectNode) getResultNode(TYPE.OBJECT);
JsonNode item = playerItem.getResultNode().get(Player.GetItem.ITEM);
addToArrayNode(resultNode, "items", item);
limitsEnd++;
}
}
/**
* JSON response for Playlist.GetPlaylists response
*
* Example:
* Query: {"jsonrpc":"2.0","method":"Playlist.GetPlaylists","id":31}
* Response: {"id":31,"jsonrpc":"2.0","result":[{"playlistid":0,"type":"audio"},{"playlistid":1,"type":"video"},{"playlistid":2,"type":"picture"}]}
*/
public static class GetPlaylists extends JsonResponse {
public final static String METHOD_NAME = "Playlist.GetPlaylists";
public GetPlaylists(int id) {
super(id);
ArrayNode playlists = createArrayNode();
playlists.add(createPlaylistNode(playlistID.AUDIO.ordinal(), "audio"));
playlists.add(createPlaylistNode(playlistID.VIDEO.ordinal(), "video"));
playlists.add(createPlaylistNode(playlistID.PICTURE.ordinal(), "picture"));
setResultToResponse(playlists);
}
private ObjectNode createPlaylistNode(int id, String type) {
ObjectNode playlistNode = createObjectNode();
playlistNode.put("playlistid", id);
playlistNode.put("type", type);
return playlistNode;
}
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
public class Application {
/**
* JSON response for Application.OnVolumeChanged notification
*
* Example:
* Answer: {"jsonrpc":"2.0","method":"Application.OnVolumeChanged","params":{"data":{"muted":false,"volume":100},"sender":"xbmc"}}
*
* @return JSON string
*/
public static class OnVolumeChanged extends JsonResponse {
public final static String METHOD_NAME = "Application.OnVolumeChanged";
public OnVolumeChanged(boolean muteState, int volume) {
super();
addMethodToResponse(METHOD_NAME);
addDataToResponse("volume", volume);
addDataToResponse("muted", muteState);
addParameterToResponse("sender", "xbmc");
}
}
}

View file

@ -0,0 +1,190 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonUtils;
public class Player {
abstract public static class PlayPause extends JsonResponse {
private PlayPause(String methodName, int itemId, String itemType, int playerId, int speed) {
addMethodToResponse(methodName);
ObjectNode itemNode = createObjectNode();
itemNode.put("id", itemId);
if (itemType != null)
itemNode.put("type", itemType);
addDataToResponse("item", itemNode);
itemNode = createObjectNode();
itemNode.put("playerid", playerId);
itemNode.put("speed", speed);
addDataToResponse("player", itemNode);
addParameterToResponse("sender", "xbmc");
}
}
/**
* JSON response for Player.OnSpeedChanged notification
*
* Example:
* Answer: {"jsonrpc":"2.0","method":"Player.OnSpeedChanged","params":{"data":{"item":{"id":94,"type":"song"},"player":{"playerid":0,"speed":0}},"sender":"xbmc"}}
*/
public static class OnSpeedChanged extends PlayPause {
public final static String METHOD_NAME = "Player.OnSpeedChanged";
public OnSpeedChanged(int itemId, String itemType, int playerId, int speed) {
super(METHOD_NAME, itemId, itemType, playerId, speed);
}
}
/**
* JSON response for Player.OnPause notification
*
* Example:
* Answer: {"jsonrpc":"2.0","method":"Player.OnPause","params":{"data":{"item":{"id":94,"type":"song"},"player":{"playerid":0,"speed":0}},"sender":"xbmc"}}
*/
public static class OnPause extends PlayPause {
public final static String METHOD_NAME = "Player.OnPause";
public OnPause(int itemId, String itemType, int playerId, int speed) {
super(METHOD_NAME, itemId, itemType, playerId, speed);
}
}
/**
* JSON response for Player.OnPlay notification
*
* Example:
* Answer: {"jsonrpc":"2.0","method":"Player.OnPlay","params":{"data":{"item":{"id":1580,"type":"song"},"player":{"playerid":0,"speed":1}},"sender":"xbmc"}}
*/
public static class OnPlay extends PlayPause {
public final static String METHOD_NAME = "Player.OnPlay";
public OnPlay(int itemId, String itemType, int playerId, int speed) {
super(METHOD_NAME, itemId, itemType, playerId, speed);
}
}
/**
* JSON response for Player.OnStop notification
*
* Example:
* {"jsonrpc":"2.0","method":"Player.OnStop","params":{"data":{"end":false,"item":{"id":14765,"type":"song"}},"sender":"xbmc"}}
*/
public static class OnStop extends JsonResponse {
public final static String METHOD_NAME = "Player.OnStop";
public OnStop(int itemId, String itemType, boolean ended) {
super();
addMethodToResponse(METHOD_NAME);
addDataToResponse("end", false);
ObjectNode itemNode = createObjectNode();
itemNode.put("id", itemId);
itemNode.put("type", itemType);
addDataToResponse("item", itemNode);
addParameterToResponse("sender", "xbmc");
}
}
/**
* JSON response for Player.OnPropertyChanged notification
*
* Example:
* {"jsonrpc":"2.0","method":"Player.OnPropertyChanged","params":{"data":{"player":{"playerid":0},"property":{"repeat":"all"}},"sender":"xbmc"}}
*/
public static class OnPropertyChanged extends JsonResponse {
public final static String METHOD_NAME = "Player.OnPropertyChanged";
public OnPropertyChanged(String repeatType, Boolean shuffled, int playerId) {
super();
addMethodToResponse(METHOD_NAME);
ObjectNode playerIdNode = createObjectNode();
playerIdNode.put("playerid", playerId);
addDataToResponse("player", playerIdNode);
if (repeatType != null) {
ObjectNode repeatNode = createObjectNode();
repeatNode.put("repeat", repeatType);
addDataToResponse("property", repeatNode);
}
if (shuffled != null) {
ObjectNode repeatNode = createObjectNode();
repeatNode.put("shuffled", shuffled);
addDataToResponse("property", repeatNode);
}
addParameterToResponse("sender", "xbmc");
}
}
/**
* JSON response for Player.OnSeek notification
*
* Example:
* {"jsonrpc":"2.0","method":"Player.OnSeek", "params":{ "data":{"item":{ "id":127,"type":"episode" },"player":{ "playerid":1,"seekoffset":{ "hours":0,"milliseconds":0, "minutes":0,"seconds":-14 },"speed":0, "time":{"hours":0, "milliseconds":0,"minutes":0, "seconds":2} }},"sender":"xbmc" }}
*/
public static class OnSeek extends JsonResponse {
public final static String METHOD_NAME = "Player.OnSeek";
public OnSeek(int itemId, String type, int playerId, int speed, long seekOffsetSecs, long timeSecs) {
super();
addMethodToResponse(METHOD_NAME);
ObjectNode itemNode = createObjectNode();
itemNode.put("id", itemId);
itemNode.put("type", type);
addDataToResponse("item", itemNode);
ObjectNode playerNode = createObjectNode();
playerNode.put("playerid", playerId);
playerNode.set("seekoffset", JsonUtils.createTimeNode(createObjectNode(), seekOffsetSecs));
playerNode.set("time", JsonUtils.createTimeNode(createObjectNode(), timeSecs));
playerNode.put("speed", speed);
addDataToResponse("player", playerNode);
addParameterToResponse("sender", "xbmc");
}
}
/**
* JSON response for Player.OnAVStart notification
*
* Example:
* {"jsonrpc":"2.0","method":"Player.OnAVStart",
* "params":{"data":{
* "item":{"id":1502,"type":"song"},
* "player":{"playerid":0,"speed":1}},
* "sender":"xbmc"}}
*/
public static class OnAVStart extends PlayPause {
public final static String METHOD_NAME = "Player.OnAVStart";
public OnAVStart(int itemId, String itemType, int playerId, int speed) {
super(METHOD_NAME, itemId, itemType, playerId, speed);
}
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.response.notifications;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.testutils.tcpserver.handlers.jsonrpc.JsonResponse;
public class Playlist {
/**
* JSON response for Playlist.OnClear notification
*
* Example:
* {"jsonrpc":"2.0","method":"Playlist.OnClear","params":{"data":{"playlistid":0},"sender":"xbmc"}}
*/
public static class OnClear extends JsonResponse {
public final static String METHOD_NAME = "Playlist.OnClear";
public OnClear(int playlistId) {
super();
addMethodToResponse(METHOD_NAME);
addDataToResponse("playlistid", playlistId);
addParameterToResponse("sender", "xbmc");
}
}
/**
* JSON response for Playlist.OnAdd notification
*
* Example:
* {"jsonrpc":"2.0","method":"Playlist.OnAdd","params":{"data":{"item":{"id":1502,"type":"song"},"playlistid":0,"position":0},"sender":"xbmc"}}
*/
public static class OnAdd extends JsonResponse {
public final static String METHOD_NAME = "Playlist.OnAdd";
public OnAdd(int itemId, String type, int playlistId, int playlistPosition) {
addMethodToResponse(METHOD_NAME);
ObjectNode item = createObjectNode();
item.put("id", itemId);
item.put("type", type);
addDataToResponse("item", item);
addDataToResponse("playlistid", playlistId);
addDataToResponse("position", playlistPosition);
addParameterToResponse("sender", "xbmc");
}
}
}

View file

@ -0,0 +1,224 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Added notification permission so that code in MediaSessionService doesn't show errors.
This is not really necessary, given that notifications are sent via MediaService, and those
are exempt from being explicitly requested but it doesn't hurt
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Dangerous permissions -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config">
<!-- Activities -->
<activity
android:name=".ui.sections.remote.RemoteActivity"
android:exported="true">
<!-- Main intent filter to open this activity from the launcher -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
</activity>
<!-- Auxiliary activity to handle "Play on Kodi" share intents -->
<activity
android:name=".ShareOpenActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<!-- Send image/video/audio directly to Kodi -->
<intent-filter android:label="@string/play_on_kodi">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
</intent-filter>
<!--
Open plain text URLs (eg, from youtube player, chrome when on a supported site, etc).
This is a very generic filter, but it's the only way to get sharing requests from the youtube app
-->
<intent-filter android:label="@string/play_on_kodi">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Open URLs of image/video/audio -->
<intent-filter android:label="@string/play_on_kodi">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent-filter>
<!-- Open supported URLs - youtube, vimeo, svtplay, etc. -->
<intent-filter android:label="@string/play_on_kodi">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="vimeo.com" />
<data android:host="www.vimeo.com" />
<data android:host="player.vimeo.com" />
<data android:host="www.svtplay.se" />
<data android:host="soundcloud.com" />
<data android:host="m.soundcloud.com" />
<data android:host="www.arte.tv" />
<data android:host="twitch.tv" />
<data android:host="m.twitch.tv" />
</intent-filter>
<!-- To provide backwards compatibility with the old DirectShare API -->
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- Auxiliary activity to handle "Queue on Kodi" share intents -->
<activity
android:name=".ShareQueueActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<!-- See intent filters on Share Open Activity, as these are similar -->
<intent-filter android:label="@string/queue_on_kodi">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
</intent-filter>
<intent-filter android:label="@string/queue_on_kodi">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/queue_on_kodi">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent-filter>
<intent-filter android:label="@string/queue_on_kodi">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="youtu.be" />
<data android:host="youtube.com" />
<data android:host="m.youtube.com" />
<data android:host="www.youtube.com" />
<data android:host="vimeo.com" />
<data android:host="www.vimeo.com" />
<data android:host="player.vimeo.com" />
<data android:host="www.svtplay.se" />
<data android:host="soundcloud.com" />
<data android:host="m.soundcloud.com" />
<data android:host="www.arte.tv" />
<data android:host="twitch.tv" />
<data android:host="m.twitch.tv" />
</intent-filter>
</activity>
<activity android:name=".ui.sections.hosts.HostManagerActivity" />
<activity android:name=".ui.sections.hosts.AddHostActivity" />
<activity android:name=".ui.sections.hosts.EditHostActivity" />
<activity android:name=".ui.sections.video.MoviesActivity" />
<activity android:name=".ui.sections.video.TVShowsActivity" />
<activity android:name=".ui.sections.audio.MusicActivity" />
<activity android:name=".ui.sections.addon.AddonsActivity" />
<activity android:name=".ui.sections.settings.SettingsActivity" />
<activity android:name=".ui.sections.file.FileActivity" />
<activity android:name=".ui.sections.localfile.LocalFileActivity" />
<activity android:name=".ui.sections.video.PVRActivity" />
<activity android:name=".ui.sections.video.AllCastActivity" />
<activity android:name=".ui.sections.favourites.FavouritesActivity" />
<!-- Providers -->
<provider
android:name=".provider.MediaProvider"
android:authorities="org.xbmc.kore.provider"
android:exported="false" />
<!-- Services -->
<service
android:name=".service.library.LibrarySyncService"
android:exported="false" />
<service
android:name=".service.MediaSessionService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<!-- Broadcast Receivers -->
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<uses-library android:required="false" android:name="com.sec.android.app.multiwindow"/>
<meta-data android:name="com.sec.android.support.multiwindow" android:value="true" />
<meta-data android:name="com.sec.android.multiwindow.DEFAULT_SIZE_W" android:value="632.0dip" />
<meta-data android:name="com.sec.android.multiwindow.DEFAULT_SIZE_H" android:value="598.0dip" />
<meta-data android:name="com.sec.android.multiwindow.MINIMUM_SIZE_W" android:value="632.0dip" />
<meta-data android:name="com.sec.android.multiwindow.MINIMUM_SIZE_H" android:value="598.0dip" />
<meta-data android:name="com.lge.support.SPLIT_WINDOW" android:value="true" />
</application>
</manifest>

View file

@ -0,0 +1,275 @@
/*
* Copyright 2015 Synced Synapse. 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 org.xbmc.kore;
import android.app.DownloadManager;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import android.text.format.DateUtils;
import org.xbmc.kore.utils.LogUtils;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* Class that contains various constants and the keys for settings stored in shared preferences
*/
public class Settings {
private static final String TAG = LogUtils.makeLogTag(Settings.class);
/**
* The update interval for the records in the DB. If the last update is older than this value
* a refresh will be triggered. Applicable to TV Shows and Movies.
*/
// public static final long DB_UPDATE_INTERVAL = 12 * DateUtils.HOUR_IN_MILLIS;
public static final long DB_UPDATE_INTERVAL = 5 * DateUtils.MINUTE_IN_MILLIS;
// Sort orders
public static final int SORT_BY_NAME = 0,
SORT_BY_DATE_ADDED = 1,
SORT_BY_RATING = 2,
SORT_BY_YEAR = 3,
SORT_BY_LENGTH = 4,
SORT_BY_ALBUM = 5,
SORT_BY_ARTIST = 6,
SORT_BY_ARTIST_YEAR = 7,
SORT_BY_LAST_PLAYED = 8,
UNSORTED = 9;
/**
* Preferences keys.
* Some of these settings are automatically managed by the Preferences mechanism.
* Make sure these are the same as in preferences.xml
*/
//Theme color and variant constants, keep in sync to the defined in arrays.xml
public static final String THEME_VARIANT_LIGHT = "light", THEME_VARIANT_DARK = "dark", THEME_VARIANT_SYSTEM = "auto";
public static final String THEME_COLOR_KORE = "kore", THEME_COLOR_GREEN = "green",
THEME_COLOR_YELLOW = "yellow", THEME_COLOR_PURPLE = "purple", THEME_COLOR_SYSTEM = "system_colors";
// Theme
public static final String KEY_PREF_THEME_COLOR = "pref_theme_color";
public static final String DEFAULT_PREF_THEME_COLOR = THEME_COLOR_KORE;
public static final String KEY_PREF_THEME_VARIANT = "pref_theme_variant";
public static final String DEFAULT_PREF_THEME_VARIANT = THEME_VARIANT_SYSTEM;
// Switch to remote
public static final String KEY_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START = "pref_switch_to_remote_after_media_start";
public static final boolean DEFAULT_PREF_SWITCH_TO_REMOTE_AFTER_MEDIA_START = true;
// Keep remote activity above lockscreen
public static final String KEY_PREF_KEEP_REMOTE_ABOVE_LOCKSCREEN = "pref_keep_remote_above_lockscreen";
public static final boolean DEFAULT_KEY_PREF_KEEP_REMOTE_ABOVE_LOCKSCREEN = false;
// Keep screen on when on the remote activity
public static final String KEY_PREF_KEEP_SCREEN_ON = "pref_keep_screen_on";
public static final boolean DEFAULT_KEY_PREF_KEEP_SCREEN_ON = false;
// Show now playing panel
public static final String KEY_PREF_SHOW_NOW_PLAYING_PANEL = "pref_show_nowplayingpanel";
public static final boolean DEFAULT_PREF_SHOW_NOW_PLAYING_PANEL = true;
// Pause during calls
public static final String KEY_PREF_PAUSE_DURING_CALLS = "pref_pause_during_calls";
public static final boolean DEFAULT_PREF_PAUSE_DURING_CALLS = false;
// Other keys used in preferences.xml
public static final String KEY_PREF_ABOUT = "pref_about";
// Filter watched movies on movie list
public static final String KEY_PREF_MOVIES_FILTER_HIDE_WATCHED = "movies_filter_hide_watched";
public static final boolean DEFAULT_PREF_MOVIES_FILTER_HIDE_WATCHED = false;
// Sort order on movies
public static final String KEY_PREF_MOVIES_SORT_ORDER = "movies_sort_order";
public static final int DEFAULT_PREF_MOVIES_SORT_ORDER = SORT_BY_NAME;
// Show watched status on movie list
public static final String KEY_PREF_MOVIES_SHOW_WATCHED_STATUS = "movies_show_watched_status";
public static final boolean DEFAULT_PREF_MOVIES_SHOW_WATCHED_STATUS = true;
// Show watched status on movie list
public static final String KEY_PREF_MOVIES_SHOW_RATING = "movies_show_rating";
public static final boolean DEFAULT_PREF_MOVIES_SHOW_RATING = true;
// Sort order on albums
public static final String KEY_PREF_ALBUMS_SORT_ORDER = "albums_sort_order";
public static final int DEFAULT_PREF_ALBUMS_SORT_ORDER = SORT_BY_ALBUM;
// Ignore articles on movie sorting
public static final String KEY_PREF_MOVIES_IGNORE_PREFIXES = "movies_ignore_prefixes";
public static final boolean DEFAULT_PREF_MOVIES_IGNORE_PREFIXES = false;
// Filter watched tv shows on tvshows list
public static final String KEY_PREF_TVSHOWS_FILTER_HIDE_WATCHED = "tvshows_filter_hide_watched";
public static final boolean DEFAULT_PREF_TVSHOWS_FILTER_HIDE_WATCHED = false;
// Filter watched episodes on episodes list
public static final String KEY_PREF_TVSHOW_EPISODES_FILTER_HIDE_WATCHED = "tvshow_episodes_filter_hide_watched";
public static final boolean DEFAULT_PREF_TVSHOW_EPISODES_FILTER_HIDE_WATCHED = false;
// Sort order on tv shows
public static final String KEY_PREF_TVSHOWS_SORT_ORDER = "tvshows_sort_order";
public static final int DEFAULT_PREF_TVSHOWS_SORT_ORDER = SORT_BY_NAME;
// Ignore articles on tv show sorting
public static final String KEY_PREF_TVSHOWS_IGNORE_PREFIXES = "tvshows_ignore_prefixes";
public static final boolean DEFAULT_PREF_TVSHOWS_IGNORE_PREFIXES = false;
// Show watched status on movie list
public static final String KEY_PREF_TVSHOWS_SHOW_WATCHED_STATUS = "tvshows_show_watched_status";
public static final boolean DEFAULT_PREF_TVSHOWS_SHOW_WATCHED_STATUS = true;
// Filter watched pvr recordings on movie list
public static final String KEY_PREF_PVR_RECORDINGS_FILTER_HIDE_WATCHED = "pvr_recordings_filter_hide_watched";
public static final boolean DEFAULT_PREF_PVR_RECORDINGS_FILTER_HIDE_WATCHED = false;
// Sort order on pvr recordings
public static final String KEY_PREF_PVR_RECORDINGS_SORT_ORDER = "pvr_recordings_sort_order";
public static final int DEFAULT_PREF_PVR_RECORDINGS_SORT_ORDER = UNSORTED;
// Filter disabled addons on addons list
public static final String KEY_PREF_ADDONS_FILTER_HIDE_DISABLED = "addons_filter_hide_disabled";
public static final boolean DEFAULT_PREF_ADDONS_FILTER_HIDE_DISABLED = false;
// Use hardware volume keys to control volume
public static final String USE_HW_VOL_KEYS_NEVER = "never", USE_HW_VOL_KEYS_ALWAYS = "always",
USE_HW_VOL_KEYS_WHEN_IN_FOREGROUND = "when_in_foreground";
public static final String KEY_PREF_USE_HW_VOL_KEYS = "pref_use_hw_vol_keys";
public static final String DEFAULT_PREF_USE_HW_VOL_KEYS = USE_HW_VOL_KEYS_NEVER;
// Vibrate on remote button press
public static final String KEY_PREF_VIBRATE_REMOTE_BUTTONS = "pref_vibrate_remote_buttons";
public static final boolean DEFAULT_PREF_VIBRATE_REMOTE_BUTTONS = false;
// Current host id
public static final String KEY_PREF_CURRENT_HOST_ID = "current_host_id";
public static final int DEFAULT_PREF_CURRENT_HOST_ID = -1;
public static final String KEY_PREF_REMOTE_BAR_ITEMS = "pref_remote_bar_items";
public static String getRemoteBarItemsPrefKey(int hostId) {
return Settings.KEY_PREF_REMOTE_BAR_ITEMS + hostId;
}
public static final String KEY_PREF_ALWAYS_SENDTOKODI_ADDON = "pref_always_sendtokodi_addon";
public static final boolean DEFAULT_PREF_ALWAYS_SENDTOKODI_ADDON = false;
public static final String KEY_PREF_YOUTUBE_ADDON_ID = "pref_youtube_addon_id";
public static final String DEFAULT_PREF_YOUTUBE_ADDON_ID = "plugin.video.youtube";
public static final String KEY_PREF_NAV_DRAWER_ITEMS = "pref_nav_drawer_items";
public static String getNavDrawerItemsPrefKey(int hostId) {
return Settings.KEY_PREF_NAV_DRAWER_ITEMS + hostId;
}
public static final String KEY_PREF_DOWNLOAD_TYPES = "pref_download_conn_types";
public static final String KEY_PREF_SINGLE_COLUMN = "pref_single_multi_column";
public static final boolean DEFAULT_PREF_SINGLE_COLUMN = false;
public static final String KEY_PREF_LANGUAGE = "pref_language";
public static final String KEY_PREF_SELECTED_LANGUAGE = "pref_selected_language";
/**
* Determines the bit flags used by {@link DownloadManager.Request} to correspond to the enabled network connections
* from the settings screen.
* @return {@link DownloadManager.Request} network types bit flags that are enabled or 0 if none are enabled
*/
public static int allowedDownloadNetworkTypes(Context context) {
SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context);
Set<String> connPrefs = sharedPref.getStringSet(Settings.KEY_PREF_DOWNLOAD_TYPES,
new HashSet<>(Arrays.asList(new String[]{"0"})));
int result = 0; // default none
for(String pref : connPrefs) {
switch( Integer.parseInt(pref) ) {
case 0:
result |= DownloadManager.Request.NETWORK_WIFI;
break;
case 1:
result |= DownloadManager.Request.NETWORK_MOBILE;
break;
case 2: // currently -1 means all network types in DownloadManager
result |= ~0;
}
}
return result;
}
/**
* Keys for bookmarked addons stored in preferences
*/
private static final String KEY_PREF_BOOKMARKED_ADDONS = "bookmarked";
public static String getBookmarkedAddonsPrefKey(int hostId) {
return Settings.KEY_PREF_BOOKMARKED_ADDONS + hostId;
}
private static final String KEY_PREF_NAME_BOOKMARKED_ADDON = "name_";
public static String getNameBookmarkedAddonsPrefKey(int hostId) {
return Settings.KEY_PREF_NAME_BOOKMARKED_ADDON + hostId + "_";
}
public static final String DEFAULT_PREF_NAME_BOOKMARKED_ADDON = "Content";
/**
* Returns a theme resource Id given the value stored in Shared Preferences
* @param prefThemeColor Shared Preferences colour for the theme
* @param prefThemeVariant Shared Preferences variant for the theme
* @return Android resource id of the theme
*/
public static int getThemeResourceId(String prefThemeColor, String prefThemeVariant) {
switch (prefThemeColor) {
case THEME_COLOR_YELLOW:
switch (prefThemeVariant) {
case THEME_VARIANT_LIGHT:
return R.style.Theme_Kore_Yellow_Light;
case THEME_VARIANT_DARK:
return R.style.Theme_Kore_Yellow_Dark;
default:
return R.style.Theme_Kore_Yellow_Auto;
}
case THEME_COLOR_PURPLE:
switch (prefThemeVariant) {
case THEME_VARIANT_LIGHT:
return R.style.Theme_Kore_Purple_Light;
case THEME_VARIANT_DARK:
return R.style.Theme_Kore_Purple_Dark;
default:
return R.style.Theme_Kore_Purple_Auto;
}
case THEME_COLOR_GREEN:
switch (prefThemeVariant) {
case THEME_VARIANT_LIGHT:
return R.style.Theme_Kore_Green_Light;
case THEME_VARIANT_DARK:
return R.style.Theme_Kore_Green_Dark;
default:
return R.style.Theme_Kore_Green_Auto;
}
default: // "kore" and "system_colors" share this
switch (prefThemeVariant) {
case THEME_VARIANT_LIGHT:
return R.style.Theme_Kore_Default_Light;
case THEME_VARIANT_DARK:
return R.style.Theme_Kore_Default_Dark;
default:
return R.style.Theme_Kore_Default_Auto;
}
}
}
}

View file

@ -0,0 +1,302 @@
package org.xbmc.kore;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.webkit.MimeTypeMap;
import android.widget.Toast;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.preference.PreferenceManager;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.host.HostManager;
import org.xbmc.kore.host.actions.OpenSharedUrl;
import org.xbmc.kore.jsonrpc.ApiCallback;
import org.xbmc.kore.jsonrpc.type.PlaylistType;
import org.xbmc.kore.ui.sections.localfile.HttpApp;
import org.xbmc.kore.ui.sections.remote.RemoteActivity;
import org.xbmc.kore.utils.LogUtils;
import org.xbmc.kore.utils.PluginUrlUtils;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Auxiliary activity with no UI that handles share intents to Play or Queue an item on Kodi.
* Decodes the passed intent, determine which methods to call on Kodi, sends the appropriate calls
* and opens the {@link RemoteActivity} if necessary.
*/
public class ShareOpenActivity extends Activity {
private static final String TAG = LogUtils.makeLogTag(ShareOpenActivity.class);
// ACTION to be used with the shortcut API that directly opens the remote
public static final String DEFAULT_OPEN_ACTION = "org.xbmc.kore.OPEN_REMOTE_VIEW";
// CATEGORY for dynamic Share Targets
public static final String SHARE_TARGET_CATEGORY = "org.xbmc.kore.SHARE_TARGET";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handleStartIntent(getIntent());
}
/**
* Handles the intent that started this activity, namely to start playing something on Kodi
* @param intent Start intent for the activity
*/
protected void handleStartIntent(Intent intent) {
handleStartIntent(intent, false);
}
/**
* Handles the intent that started this activity, namely to start playing something on Kodi
* @param intent Start intent for the activity
* @param queue Whether to queue the item
*/
protected void handleStartIntent(Intent intent, boolean queue) {
LogUtils.LOGD(TAG, "Got Share Intent: " + intent);
final HostManager hostManager = HostManager.getInstance(this);
// If a host was passed from the intent switch to it
String shortcutId = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID);
if (shortcutId != null) {
int hostId = Integer.parseInt(shortcutId);
for (HostInfo host : hostManager.getHosts()) {
if (host.getId() == hostId) {
LogUtils.LOGD(TAG, "Switching hosts");
hostManager.switchHost(host);
break;
}
}
}
final String action = intent.getAction();
final String intentType = intent.getType();
// Check action: open the Remote activity if no action specified, no host connection (no hosts configured?),
// default open specified (switch host?) or any other action other than Send or View
if (action == null ||
hostManager.getConnection() == null ||
action.equals(DEFAULT_OPEN_ACTION) ||
!(action.equals(Intent.ACTION_SEND) || action.equals(Intent.ACTION_VIEW))) {
startActivity(new Intent(this, RemoteActivity.class));
finish();
return;
}
Uri videoUri;
if (action.equals(Intent.ACTION_SEND) && intentType != null && intentType.equals("text/plain")) {
// Get the URI, which is stored in Extras
videoUri = getPlainTextUri(intent.getStringExtra(Intent.EXTRA_TEXT));
} else {
videoUri = intent.getData();
}
if (videoUri == null) {
// Check if `intent` contains a URL or a link to a local file:
videoUri = getShareLocalUriOrHiddenUri(intent);
}
if (videoUri == null) {
// Couldn't understand the URI
finish();
return;
}
String url = toPluginUrl(videoUri);
if (url == null) {
url = videoUri.toString();
}
// Determine which playlist to use
int playlistType;
if (intentType == null) {
playlistType = PlaylistType.VIDEO_PLAYLISTID;
} else if (intentType.matches("audio.*")) {
playlistType = PlaylistType.MUSIC_PLAYLISTID;
} else if (intentType.matches("video.*")) {
playlistType = PlaylistType.VIDEO_PLAYLISTID;
} else if (intentType.matches("image.*")) {
playlistType = PlaylistType.PICTURE_PLAYLISTID;
} else {
// Generic links? Default to video:
playlistType = PlaylistType.VIDEO_PLAYLISTID;
}
String title = getString(R.string.app_name);
String text = getString(R.string.item_added_to_playlist);
final Context context = this;
new OpenSharedUrl(this, url, title, text, queue, playlistType)
.execute(hostManager.getConnection(),
new ApiCallback<>() {
@Override
public void onSuccess(Boolean wasAlreadyPlaying) {
String msg = queue && wasAlreadyPlaying ? getString(R.string.item_added_to_playlist)
: getString(R.string.item_sent_to_kodi);
Toast.makeText(context, msg, Toast.LENGTH_SHORT)
.show();
}
@Override
public void onError(int errorCode, String description) {
LogUtils.LOGE(TAG, "Share failed: " + description);
Toast.makeText(context, description, Toast.LENGTH_SHORT)
.show();
}
}, new Handler(Looper.getMainLooper()));
// Don't display Kore after queueing from another app, otherwise start the remote
if (!queue)
startActivity(new Intent(this, RemoteActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP));
// Always finish as we don't have anything to show
finish();
}
private Uri getUrlInsideIntent(Intent intent) {
// Some apps hide the link in the clip, try to detect any link by casting the intent
// to string a looking with a regular expression:
Matcher matcher = Pattern.compile("https?://[^\\s]+").matcher(intent.toString());
String matchedString;
if (matcher.find()) {
matchedString = matcher.group(0);
if (matchedString != null && matchedString.endsWith("}")) {
matchedString = matchedString.substring(0, matchedString.length() - 1);
}
return Uri.parse(matchedString);
}
return null;
}
private Uri getShareLocalUriOrHiddenUri(Intent intent) {
Uri contentUri = intent.getData();
if (contentUri == null) {
Bundle bundle = intent.getExtras();
contentUri = (Uri) bundle.get(Intent.EXTRA_STREAM);
}
if (contentUri == null) {
return getUrlInsideIntent(intent);
}
HttpApp http_app;
try {
http_app = HttpApp.getInstance(getApplicationContext(), 8080);
} catch (IOException ioe) {
Toast.makeText(getApplicationContext(),
getString(R.string.error_starting_http_server),
Toast.LENGTH_LONG).show();
return null;
}
http_app.addUri(contentUri);
String url = http_app.getLinkToFile();
return Uri.parse(url);
}
/**
* Returns the Uri that the some apps passes in EXTRA_TEXT
* YouTube sends something like: [Video title]: [YouTube URL] so we need
* to get the second part
*
* @param extraText EXTRA_TEXT passed in the intent
* @return Uri present in extraText if present
*/
private Uri getPlainTextUri(String extraText) {
if (extraText == null) return null;
for (String word : extraText.split(" ")) {
if (word.startsWith("http://") || word.startsWith("https://")) {
try {
URL validUri = new URL(word);
return Uri.parse(word);
} catch (MalformedURLException exc) {
LogUtils.LOGD(TAG, "Got a malformed URL in an intent: " + word);
return null;
}
}
}
return null;
}
/**
* Converts a video url to a Kodi plugin URL.
*
* @param playuri some URL
* @return plugin URL
*/
private String toPluginUrl(Uri playuri) {
String host = playuri.getHost();
String extension = MimeTypeMap.getFileExtensionFromUrl(playuri.toString());
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (host == null)
return null;
boolean alwaysSendToKodi = PreferenceManager.getDefaultSharedPreferences(getApplicationContext())
.getBoolean(Settings.KEY_PREF_ALWAYS_SENDTOKODI_ADDON,
Settings.DEFAULT_PREF_ALWAYS_SENDTOKODI_ADDON);
if (!alwaysSendToKodi) {
if (host.endsWith("youtube.com") || host.endsWith("youtu.be")) {
String preferredYouTubeAddonId = PreferenceManager.getDefaultSharedPreferences(getApplicationContext())
.getString(Settings.KEY_PREF_YOUTUBE_ADDON_ID, Settings.DEFAULT_PREF_YOUTUBE_ADDON_ID);
if (preferredYouTubeAddonId.equals("plugin.video.invidious")) {
return PluginUrlUtils.toInvidiousYouTubePluginUrl(playuri);
} else {
return PluginUrlUtils.toDefaultYouTubePluginUrl(playuri);
}
} else if (host.endsWith("vimeo.com")) {
return PluginUrlUtils.toVimeoPluginUrl(playuri);
} else if (host.endsWith("svtplay.se")) {
return PluginUrlUtils.toSvtPlayPluginUrl(playuri);
} else if (host.endsWith("soundcloud.com")) {
return PluginUrlUtils.toSoundCloudPluginUrl(playuri);
} else if (host.endsWith("twitch.tv")) {
return PluginUrlUtils.toTwitchPluginUrl(playuri);
} else if (PluginUrlUtils.isHostArte(host)) {
return PluginUrlUtils.toArtePluginUrl(playuri);
}
}
if (host.startsWith("app.primevideo.com")) {
// Prime Video cannot be handled by SendToKodi as it requires authentication:
Matcher amazonMatcher = Pattern.compile("gti=([^&]+)").matcher(playuri.toString());
if (amazonMatcher.find()) {
String gti = amazonMatcher.group(1);
return "plugin://plugin.video.amazon-test/?asin=" + gti + "&mode=PlayVideo&adult=0&name=&trailer=0&selbitrate=0";
}
} else if (!isMediaFile(mimeType)) {
// SendToKodi is a Kodi addon that is able to extract URLs from generic
// web URIs using the Python library "youtube-dl".
// Use it as a last resort, unless the URI extension is a known media file
// (in that case Kodi does not require an addon to play the link):
return "plugin://plugin.video.sendtokodi/?" + playuri;
}
return null;
}
boolean isMediaFile(String mimeType) {
if (mimeType == null) {
return false;
} else if (mimeType.startsWith("audio")) {
return true;
} else if (mimeType.startsWith("image")) {
return true;
} else if (mimeType.startsWith("video")) {
return true;
}
return false;
}
}

View file

@ -0,0 +1,16 @@
package org.xbmc.kore;
import android.content.Intent;
/**
* Auxiliary activity with no UI that handles share intents to Queue an item on Kodi.
* Delegates to {@link ShareOpenActivity} with queue set
*/
public class ShareQueueActivity extends ShareOpenActivity {
@Override
protected void handleStartIntent(Intent intent) {
handleStartIntent(intent, true);
}
}

View file

@ -0,0 +1,191 @@
/*
* Copyright (C) 2005-2009 Team XBMC
* http://xbmc.org
*
* This Program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This Program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with XBMC Remote; see the file license. If not, write to
* the Free Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA.
* http://www.gnu.org/copyleft/gpl.html
*
*/
package org.xbmc.kore.eventclient;
/**
* Remote control and keyboard strings, taken from xbmc/ButtonTranslator.cpp
*
* @author Team XBMC
*/
public final class ButtonCodes {
/**
* "KB" => standard keyboard map ( <keyboard> section )
* "XG" => xbox gamepad map ( <gamepad> section )
* "R1" => xbox remote map ( <remote> section )
* "R2" => xbox universal remote map ( <universalremote> section )
* "LI:devicename" => LIRC remote map where 'devicename' is the actual device's name
*/
public static final String MAP_KEYBOARD = "KB";
public static final String MAP_GAMEPAD = "XG";
public static final String MAP_REMOTE = "R1";
public static final String MAP_UNIVERSAL_REMOTE = "R2";
public static final String REMOTE_LEFT = "left";
public static final String REMOTE_RIGHT = "right";
public static final String REMOTE_UP = "up";
public static final String REMOTE_DOWN = "down";
public static final String REMOTE_SELECT = "select";
public static final String REMOTE_BACK = "back";
public static final String REMOTE_MENU = "menu";
public static final String REMOTE_INFO = "info";
public static final String REMOTE_DISPLAY = "display";
public static final String REMOTE_TITLE = "title";
public static final String REMOTE_PLAY = "play";
public static final String REMOTE_PAUSE = "pause";
public static final String REMOTE_REVERSE = "reverse";
public static final String REMOTE_FORWARD = "forward";
public static final String REMOTE_SKIP_PLUS = "skipplus";
public static final String REMOTE_SKIP_MINUS = "skipminus";
public static final String REMOTE_STOP = "stop";
public static final String REMOTE_0 = "zero";
public static final String REMOTE_1 = "one";
public static final String REMOTE_2 = "two";
public static final String REMOTE_3 = "three";
public static final String REMOTE_4 = "four";
public static final String REMOTE_5 = "five";
public static final String REMOTE_6 = "six";
public static final String REMOTE_7 = "seven";
public static final String REMOTE_8 = "eight";
public static final String REMOTE_9 = "nine";
// additional keys from the media center extender for xbox remote
public static final String REMOTE_POWER = "power";
public static final String REMOTE_MY_TV = "mytv";
public static final String REMOTE_MY_MUSIC = "mymusic";
public static final String REMOTE_MY_PICTURES = "mypictures";
public static final String REMOTE_MY_VIDEOS = "myvideo";
public static final String REMOTE_RECORD = "record";
public static final String REMOTE_START = "start";
public static final String REMOTE_VOLUME_PLUS = "volumeplus";
public static final String REMOTE_VOLUME_MINUS = "volumeminus";
public static final String REMOTE_CHANNEL_PLUS = "channelplus";
public static final String REMOTE_CHANNEL_MINUS = "channelminus";
public static final String REMOTE_PAGE_PLUS = "pageplus";
public static final String REMOTE_PAGE_MINUS = "pageminus";
public static final String REMOTE_MUTE = "mute";
public static final String REMOTE_RECORDED_TV = "recordedtv";
public static final String REMOTE_GUIDE = "guide";
public static final String REMOTE_LIVE_TV = "livetv";
public static final String REMOTE_STAR = "star";
public static final String REMOTE_HASH = "hash";
public static final String REMOTE_CLEAR = "clear";
public static final String REMOTE_ENTER = "enter";
public static final String REMOTE_XBOX = "xbox";
public static final String KEYBOARD_RETURN = "return";
public static final String KEYBOARD_ENTER = "enter";
public static final String KEYBOARD_ESCAPE = "escape";
public static final String KEYBOARD_ESC = "esc";
public static final String KEYBOARD_TAB = "tab";
public static final String KEYBOARD_SPACE = "space";
public static final String KEYBOARD_LEFT = "left";
public static final String KEYBOARD_RIGHT = "right";
public static final String KEYBOARD_UP = "up";
public static final String KEYBOARD_DOWN = "down";
public static final String KEYBOARD_INSERT = "insert";
public static final String KEYBOARD_DELETE = "delete";
public static final String KEYBOARD_HOME = "home";
public static final String KEYBOARD_END = "end";
public static final String KEYBOARD_F1 = "f1";
public static final String KEYBOARD_F2 = "f2";
public static final String KEYBOARD_F3 = "f3";
public static final String KEYBOARD_F4 = "f4";
public static final String KEYBOARD_F5 = "f5";
public static final String KEYBOARD_F6 = "f6";
public static final String KEYBOARD_F7 = "f7";
public static final String KEYBOARD_F8 = "f8";
public static final String KEYBOARD_F9 = "f9";
public static final String KEYBOARD_F10 = "f10";
public static final String KEYBOARD_F11 = "f11";
public static final String KEYBOARD_F12 = "f12";
public static final String KEYBOARD_NUMPAD_ZERO = "numpadzero";
public static final String KEYBOARD_NUMPAD_1 = "numpadone";
public static final String KEYBOARD_NUMPAD_2 = "numpadtwo";
public static final String KEYBOARD_NUMPAD_3 = "numpadthree";
public static final String KEYBOARD_NUMPAD_4 = "numpadfour";
public static final String KEYBOARD_NUMPAD_5 = "numpadfive";
public static final String KEYBOARD_NUMPAD_6 = "numpadsix";
public static final String KEYBOARD_NUMPAD_7 = "numpadseven";
public static final String KEYBOARD_NUMPAD_8 = "numpadeight";
public static final String KEYBOARD_NUMPAD_9 = "numpadnine";
public static final String KEYBOARD_NUMPAD_TIMES = "numpadtimes";
public static final String KEYBOARD_NUMPAD_PLUS = "numpadplus";
public static final String KEYBOARD_NUMPAD_MINUS = "numpadminus";
public static final String KEYBOARD_NUMPAD_PERIOD = "numpadperiod";
public static final String KEYBOARD_NUMPAD_DIVIDE = "numpaddivide";
public static final String KEYBOARD_PAGEUP = "pageup";
public static final String KEYBOARD_PAGEDOWN = "pagedown";
public static final String KEYBOARD_PRINTSCREEN = "printscreen";
public static final String KEYBOARD_BACKSPACE = "backspace";
public static final String KEYBOARD_MENU = "menu";
public static final String KEYBOARD_PAUSE = "pause";
public static final String KEYBOARD_LEFTSHIFT = "leftshift";
public static final String KEYBOARD_RIGHTSHIFT = "rightshift";
public static final String KEYBOARD_LEFTCTRL = "leftctrl";
public static final String KEYBOARD_RIGHTCTRL = "rightctrl";
public static final String KEYBOARD_LEFTALT = "leftalt";
public static final String KEYBOARD_RIGHTALT = "rightalt";
public static final String KEYBOARD_LEFTWINDOWS = "leftwindows";
public static final String KEYBOARD_RIGHTWINDOWS = "rightwindows";
public static final String KEYBOARD_CAPSLOCK = "capslock";
public static final String KEYBOARD_NUMLOCK = "numlock";
public static final String KEYBOARD_SCROLLLOCK = "scrolllock";
public static final String KEYBOARD_SEMICOLON = "semicolon";
public static final String KEYBOARD_COLON = "colon";
public static final String KEYBOARD_EQUALS = "equals";
public static final String KEYBOARD_PLUS = "plus";
public static final String KEYBOARD_COMMA = "comma";
public static final String KEYBOARD_LESSTHAN = "lessthan";
public static final String KEYBOARD_MINUS = "minus";
public static final String KEYBOARD_UNDERLINE = "underline";
public static final String KEYBOARD_PERIOD = "period";
public static final String KEYBOARD_GREATERTHAN = "greaterthan";
public static final String KEYBOARD_FORWARDSLASH = "forwardslash";
public static final String KEYBOARD_QUESTIONMARK = "questionmark";
public static final String KEYBOARD_LEFTQUOTE = "leftquote";
public static final String KEYBOARD_TILDE = "tilde";
public static final String KEYBOARD_OPENSQUAREBRACKET = "opensquarebracket";
public static final String KEYBOARD_OPENBRACE = "openbrace";
public static final String KEYBOARD_BACKSLASH = "backslash";
public static final String KEYBOARD_PIPE = "pipe";
public static final String KEYBOARD_CLOSESQUAREBRACKET = "closesquarebracket";
public static final String KEYBOARD_CLOSEBRACE = "closebrace";
public static final String KEYBOARD_QUOTE = "quote";
public static final String KEYBOARD_DOUBLEQUOTE = "doublequote";
public static final String KEYBOARD_LAUNCH_MAIL = "launch_mail";
public static final String KEYBOARD_BROWSER_HOME = "browser_home";
public static final String KEYBOARD_BROWSER_FAVORITES = "browser_favorites";
public static final String KEYBOARD_BROWSER_REFRESH = "browser_refresh";
public static final String KEYBOARD_BROWSER_SEARCH = "browser_search";
public static final String KEYBOARD_LAUNCH_APP1_PC_ICON = "launch_app1_pc_icon";
public static final String KEYBOARD_LAUNCH_MEDIA_SELECT = "launch_media_select";
public static final String KEYBOARD_PLAY_PAUSE = "play_pause";
public static final String KEYBOARD_STOP = "stop";
public static final String KEYBOARD_VOLUME_UP = "volume_up";
public static final String KEYBOARD_VOLUME_MUTE = "volume_mute";
public static final String KEYBOARD_VOLUME_DOWN = "volume_down";
public static final String KEYBOARD_PREV_TRACK = "prev_track";
public static final String KEYBOARD_NEXT_TRACK = "next_track";
public static final String GAMEPAD_LEFT_ANALOG_TRIGGER = "leftanalogtrigger";
public static final String GAMEPAD_RIGHT_ANALOG_TRIGGER = "rightanalogtrigger";
}

View file

@ -0,0 +1,313 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.util.Locale;
/**
* Event Client Class
*
* Implements an XBMC-Client. This class can be used to implement your own application which
* should act as a Input device for XBMC. Also starts a Ping-Thread, which tells the XBMC EventServer
* that the client is alive. Therefore if you close your application you SHOULD call stopClient()!
* @author Stefan Agner
*
*/
public class EventClient
{
private final boolean hasIcon;
private String deviceName;
private PingThread oPingThread;
private byte iconType = Packet.ICON_PNG;
private byte[] iconData;
private InetAddress hostAddress;
private int hostPort;
/**
* Starts a XBMC EventClient.
* @param hostAddress Address of the Host running XBMC
* @param hostPort Port of the Host running XBMC (default 9777)
* @param deviceName Name of the Device
* @param iconFile Path to the Iconfile (PNG, JPEG or GIF)
* @throws IOException Exception
*/
public EventClient(InetAddress hostAddress, int hostPort, String deviceName, String iconFile) throws IOException
{
byte iconType = Packet.ICON_PNG;
// Assume png as icon type
if(iconFile.toLowerCase(Locale.US).endsWith(".jpeg"))
iconType = Packet.ICON_JPEG;
if(iconFile.toLowerCase(Locale.US).endsWith(".jpg"))
iconType = Packet.ICON_JPEG;
if(iconFile.toLowerCase(Locale.US).endsWith(".gif"))
iconType = Packet.ICON_GIF;
// Read the icon file to the byte array...
FileInputStream iconFileStream = new FileInputStream(iconFile);
byte[] iconData = new byte[iconFileStream.available()];
iconFileStream.read(iconData);
hasIcon = true;
// Call start-Method...
startClient(hostAddress, hostPort, deviceName, iconType, iconData);
}
/**
* Starts a XBMC EventClient.
* @param hostAddress Address of the Host running XBMC
* @param hostPort Port of the Host running XBMC (default 9777)
* @param deviceName Name of the Device
* @param iconType Type of the icon file (see Packet.ICON_PNG, Packet.ICON_JPEG or Packet.ICON_GIF)
* @param iconData The icon itself as a Byte-Array
*/
public EventClient(InetAddress hostAddress, int hostPort, String deviceName, byte iconType, byte[] iconData) throws IOException
{
hasIcon = true;
startClient(hostAddress, hostPort, deviceName, iconType, iconData);
}
/**
* Starts a XBMC EventClient without an icon.
* @param hostAddress Address of the Host running XBMC
* @param hostPort Port of the Host running XBMC (default 9777)
* @param deviceName Name of the Device
*/
public EventClient(InetAddress hostAddress, int hostPort, String deviceName) throws IOException
{
hasIcon = false;
byte iconType = Packet.ICON_NONE;
byte[] iconData = null;
startClient(hostAddress, hostPort, deviceName, iconType, iconData);
}
/**
* Starts a XBMC EventClient.
* @param hostAddress Address of the Host running XBMC
* @param hostPort Port of the Host running XBMC (default 9777)
* @param deviceName Name of the Device
* @param iconType Type of the icon file (see Packet.ICON_PNG, Packet.ICON_JPEG or Packet.ICON_GIF)
* @param iconData The icon itself as a Byte-Array
*/
private void startClient(InetAddress hostAddress, int hostPort, String deviceName, byte iconType, byte[] iconData) throws IOException
{
// Save host address and port
this.hostAddress = hostAddress;
this.hostPort = hostPort;
this.deviceName = deviceName;
this.iconType = iconType;
this.iconData = iconData;
// Send Hello Packet...
PacketHELO p;
if(hasIcon)
p = new PacketHELO(deviceName, iconType, iconData);
else
p = new PacketHELO(deviceName);
p.send(hostAddress, hostPort);
// Start Thread (for Ping packets...)
oPingThread = new PingThread(hostAddress, hostPort, 20000);
oPingThread.start();
}
/**
* Stops the XBMC EventClient (especially the Ping-Thread)
*/
public void stopClient() throws IOException
{
// Stop Ping-Thread...
oPingThread.giveup();
oPingThread.interrupt();
PacketBYE p = new PacketBYE();
p.send(hostAddress, hostPort);
}
/**
* Displays a notification window in XBMC.
* @param title Message title
* @param message The actual message
*/
public void sendNotification(String title, String message) throws IOException
{
PacketNOTIFICATION p;
if(hasIcon)
p = new PacketNOTIFICATION(title, message, iconType, iconData);
else
p = new PacketNOTIFICATION(title, message);
p.send(hostAddress, hostPort);
}
/**
* Sends a Button event
* @param code raw button code (default: 0)
* @param repeat this key press should repeat until released (default: 1)
* Note that queued pressed cannot repeat.
* @param down if this is 1, it implies a press event, 0 implies a release
* event. (default: 1)
* @param queue a queued key press means that the button event is
* executed just once after which the next key press is processed.
* It can be used for macros. Currently there is no support for
* time delays between queued presses. (default: 0)
* @param amount unimplemented for now; in the future it will be used for
* specifying magnitude of analog key press events
* @param axis Axis
*/
public void sendButton(short code, boolean repeat, boolean down, boolean queue, short amount, byte axis) throws IOException
{
PacketBUTTON p = new PacketBUTTON(code, repeat, down, queue, amount, axis);
p.send(hostAddress, hostPort);
}
/**
* Sends a Button event
* @param map_name a combination of map_name and button_name refers to a
* mapping in the user's Keymap.xml or Lircmap.xml.
* map_name can be one of the following:
* <ul>
* <li>"KB" => standard keyboard map ( <keyboard> section )</li>
* <li>"XG" => xbox gamepad map ( <gamepad> section )</li>
* <li>"R1" => xbox remote map ( <remote> section )</li>
* <li>"R2" => xbox universal remote map ( <universalremote> section )</li>
* <li>"LI:devicename" => LIRC remote map where 'devicename' is the
* actual device's name</li></ul>
* @param button_name a button name defined in the map specified in map_name.
* For example, if map_name is "KB" refering to the <keyboard> section in Keymap.xml
* then, valid button_names include "printscreen", "minus", "x", etc.
* @param repeat this key press should repeat until released (default: 1)
* Note that queued pressed cannot repeat.
* @param down if this is 1, it implies a press event, 0 implies a release
* event. (default: 1)
* @param queue a queued key press means that the button event is
* executed just once after which the next key press is processed.
* It can be used for macros. Currently there is no support for
* time delays between queued presses. (default: 0)
* @param amount unimplemented for now; in the future it will be used for
* specifying magnitude of analog key press events
* @param axis Axis
*/
public void sendButton(String map_name, String button_name, boolean repeat, boolean down, boolean queue, short amount, byte axis) throws IOException
{
PacketBUTTON p = new PacketBUTTON(map_name, button_name, repeat, down, queue, amount, axis);
p.send(hostAddress, hostPort);
}
/**
* Sets the mouse position in XBMC
* @param x horitontal position ranging from 0 to 65535
* @param y vertical position ranging from 0 to 65535
*/
public void sendMouse(int x, int y) throws IOException
{
PacketMOUSE p = new PacketMOUSE(x, y);
p.send(hostAddress, hostPort);
}
/**
* Sends a ping to the XBMC EventServer
*/
public void ping() throws IOException
{
PacketPING p = new PacketPING();
p.send(hostAddress, hostPort);
}
/**
* Tells XBMC to log the message to xbmc.log with the loglevel as specified.
* @param loglevel the loglevel, follows XBMC standard.
* <ul>
* <li>0 = DEBUG</li>
* <li>1 = INFO</li>
* <li>2 = NOTICE</li>
* <li>3 = WARNING</li>
* <li>4 = ERROR</li>
* <li>5 = SEVERE</li>
* </ul>
* @param logmessage the message to log
*/
public void sendLog(byte loglevel, String logmessage) throws IOException
{
PacketLOG p = new PacketLOG(loglevel, logmessage);
p.send(hostAddress, hostPort);
}
/**
* Tells XBMC to do the action specified, based on the type it knows were it needs to be sent.
* @param actionmessage Actionmessage (as in scripting/skinning)
*/
public void sendAction(String actionmessage) throws IOException
{
PacketACTION p = new PacketACTION(actionmessage);
p.send(hostAddress, hostPort);
}
/**
* Implements a PingThread which tells XBMC EventServer that the Client is alive (this should
* be done at least every 60 seconds!
* @author Stefan Agner
*
*/
static class PingThread extends Thread
{
private final InetAddress hostAddress;
private final int hostPort;
private final int sleepTime;
private boolean giveup = false;
public PingThread(InetAddress hostAddress, int hostPort, int sleepTime)
{
super("XBMC EventClient Ping-Thread");
this.hostAddress = hostAddress;
this.hostPort = hostPort;
this.sleepTime = sleepTime;
}
public void giveup()
{
giveup = true;
}
public void run()
{
while(!giveup)
{
try {
PacketPING p = new PacketPING();
p.send(hostAddress, hostPort);
} catch (IOException e) {
e.printStackTrace();
}
try {
Thread.sleep(sleepTime);
} catch (InterruptedException ignored) {
}
}
}
}
}

View file

@ -0,0 +1,268 @@
/*
* Copyright 2015 Synced Synapse. 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 org.xbmc.kore.eventclient;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Process;
import org.xbmc.kore.host.HostInfo;
import org.xbmc.kore.jsonrpc.ApiCallback;
import org.xbmc.kore.host.HostConnection;
import org.xbmc.kore.jsonrpc.method.Application;
import org.xbmc.kore.jsonrpc.type.ApplicationType;
import org.xbmc.kore.utils.LogUtils;
import org.xbmc.kore.utils.NetUtils;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* Class that establishes and maintains a connection to Kodi's EventServer
* This class keeps pinging Kodi to keep the connection alive and contains
* auxiliary methods that allow the sending of packets to Kodi.
* Make sure to call quit() when done with it, so that it gracefully shuts down
*/
public class EventServerConnection {
private static final String TAG = LogUtils.makeLogTag(EventServerConnection.class);
private static final int PING_INTERVAL = 45000; // ms
private static final String DEVICE_NAME = "Kore Remote";
/**
* Host to connect too
*/
private final HostInfo hostInfo;
private InetAddress hostInetAddress = null;
// Handler on which packets will be posted, to send them asynchronously
private final Handler commHandler;
private final HandlerThread handlerThread;
private final PacketPING packetPING = new PacketPING();
private final Runnable pingRunnable = new Runnable() {
@Override
public void run() {
LogUtils.LOGD(TAG, "Pinging EventServer");
if (hostInetAddress != null) {
try {
packetPING.send(hostInetAddress, hostInfo.getEventServerPort());
} catch (IOException exc) {
LogUtils.LOGD(TAG, "Got an IOException when sending a PING Packet to Kodi's EventServer");
}
}
commHandler.postDelayed(this, PING_INTERVAL);
}
};
/**
* Interface to notify users if the connection was successful
*/
public interface EventServerConnectionCallback {
void OnConnectResult(boolean success);
}
/**
* Constructor. Starts the thread that keeps the connection alive. Make sure to call quit() when done.
* @param hostInfo Host to connect to
* @param callback Callback to call with the connection result
* @param callbackHandler Handler on which to call the callback
*/
public EventServerConnection(final HostInfo hostInfo,
final EventServerConnectionCallback callback,
final Handler callbackHandler) {
this.hostInfo = hostInfo;
LogUtils.LOGD(TAG, "Starting EventServer Thread");
// Handler thread that will keep pinging and send the requests to Kodi
handlerThread = new HandlerThread("EventServerConnection", Process.THREAD_PRIORITY_BACKGROUND);
handlerThread.start();
// Get the HandlerThread's Looper and use it for our Handler
commHandler = new Handler(handlerThread.getLooper());
// Now, get the host InetAddress in the background
commHandler.post(() -> {
try {
hostInetAddress = NetUtils.getInet4AddressByName(hostInfo.getAddress());
} catch (UnknownHostException exc) {
LogUtils.LOGD(TAG, "Got an UnknownHostException, disabling EventServer");
hostInetAddress = null;
}
// Call the callback on the caller's thread
callbackHandler.post(() -> callback.OnConnectResult(hostInetAddress != null));
if (hostInetAddress != null) {
// Start pinging
commHandler.postDelayed(pingRunnable, PING_INTERVAL);
} else {
quitHandlerThread(handlerThread);
}
});
}
/**
* Stops the HandlerThread that is being used to send packets to Kodi
*/
public void quit() {
LogUtils.LOGD(TAG, "Quiting EventServer handler thread");
quitHandlerThread(handlerThread);
}
/**
* Sends a packet to Kodi's Event Server
* Only sends the packet if connected, i.e. if quit() has not been not called
* @param p Packet to send
*/
public void sendPacket(final Packet p) {
if (!handlerThread.isAlive() || (hostInetAddress == null)) {
return;
}
LogUtils.LOGD(TAG, "Sending Packet");
commHandler.post(() -> {
try {
p.send(hostInetAddress, hostInfo.getEventServerPort());
} catch (IOException exc) {
LogUtils.LOGD(TAG, "Got an IOException when sending a packet to Kodi's EventServer");
}
});
}
/**
* Establishes a connection to the EventServer and reports the result
* @param hostInfo Host to connect to
* @param callerCallback Callback on which to post the result
* @param callerHandler Handler on which to post the callback call
*/
public static void testEventServerConnection(final HostInfo hostInfo,
final EventServerConnectionCallback callerCallback,
final Handler callerHandler) {
final HandlerThread auxThread = new HandlerThread("EventServerConnectionTest", Process.THREAD_PRIORITY_DEFAULT);
auxThread.start();
// Get the HandlerThread's Looper and use it for our Handler
final Handler auxHandler = new Handler(auxThread.getLooper());
auxHandler.post(() -> {
// Get the InetAddress
final InetAddress hostInetAddress;
try {
hostInetAddress = NetUtils.getInet4AddressByName(hostInfo.getAddress());
} catch (UnknownHostException exc) {
LogUtils.LOGD(TAG, "Couldn't get host InetAddress");
reportTestResult(callerHandler, callerCallback, false);
quitHandlerThread(auxThread);
return;
}
// Send a HELO packet
Packet p = new PacketHELO(DEVICE_NAME);
try {
p.send(hostInetAddress, hostInfo.getEventServerPort());
} catch (IOException exc) {
LogUtils.LOGD(TAG, "Couldn't send HELO packet to host");
reportTestResult(callerHandler, callerCallback, false);
quitHandlerThread(auxThread);
return;
}
// The previous checks don't really test the connection, as this is UDP. Apart from checking if
// any HostUnreachable ICMP message is returned (which may or may not happen), there's no direct way
// to check if the messages were delivered, so the solution is to force something to happen on
// Kodi and them read Kodi's state to check if it was applied.
// We are going to get the mute status of Kodi via jsonrpc, change it via EventServer and check if
// it was changed via jsonrpc, reverting it back afterwards
final HostConnection auxHostConnection = new HostConnection(
new HostInfo(hostInfo.getName(), hostInfo.getAddress(),
HostConnection.PROTOCOL_HTTP, hostInfo.getHttpPort(), hostInfo.getTcpPort(),
hostInfo.getUsername(), hostInfo.getPassword(), false, 0, hostInfo.isHttps, hostInfo.getShowAsDirectShareTarget()));
final Application.GetProperties action = new Application.GetProperties(Application.GetProperties.MUTED);
final Packet mutePacket = new PacketBUTTON(ButtonCodes.MAP_REMOTE, ButtonCodes.REMOTE_MUTE,
false, true, true, (short) 0, (byte) 0);
// Get the initial mute status
action.execute(auxHostConnection, new ApiCallback<ApplicationType.PropertyValue>() {
@Override
public void onSuccess(ApplicationType.PropertyValue result) {
final boolean initialMuteStatus = result.muted;
// Switch mute status
try {
mutePacket.send(hostInetAddress, hostInfo.getEventServerPort());
} catch (IOException exc) {
LogUtils.LOGD(TAG, "Couldn't send first MUTE packet to host");
reportTestResult(callerHandler, callerCallback, false);
quitHandlerThread(auxThread);
return;
}
// Sleep a while to make sure the previous command was executed
try {
Thread.sleep(2000);
} catch (InterruptedException exc) {
// Ignore
}
// Now get the new status and compare
action.execute(auxHostConnection, new ApiCallback<ApplicationType.PropertyValue>() {
@Override
public void onSuccess(ApplicationType.PropertyValue result) {
// Report result (mute status is different)
reportTestResult(callerHandler, callerCallback, initialMuteStatus != result.muted);
// Revert mute status
try {
mutePacket.send(hostInetAddress, hostInfo.getEventServerPort());
} catch (IOException exc) {
LogUtils.LOGD(TAG, "Couldn't revert MUTE status");
}
quitHandlerThread(auxThread);
}
@Override
public void onError(int errorCode, String description) {
LogUtils.LOGD(TAG, "Got an error on Application.GetProperties: " + description);
reportTestResult(callerHandler, callerCallback, false);
quitHandlerThread(auxThread);
}
}, auxHandler);
}
@Override
public void onError(int errorCode, String description) {
LogUtils.LOGD(TAG, "Got an error on Application.GetProperties: " + description);
reportTestResult(callerHandler, callerCallback, false);
quitHandlerThread(auxThread);
}
}, auxHandler);
});
}
private static void reportTestResult(final Handler callerHandler,
final EventServerConnectionCallback callback,
final boolean result) {
callerHandler.post(() -> callback.OnConnectResult(result));
}
private static void quitHandlerThread(HandlerThread handlerThread) {
handlerThread.quitSafely();
}
}

View file

@ -0,0 +1,288 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
/**
* XBMC Event Client Class
* <p>
* Implementation of XBMC's UDP based input system.
* A set of classes that abstract the various packets that the event server
* currently supports. In addition, there's also a class, XBMCClient, that
* provides functions that sends the various packets. Use XBMCClient if you
* don't need complete control over packet structure.
* </p>
* <p>
* The basic workflow involves:
* <ol>
* <li>Send a HELO packet</li>
* <li>Send x number of valid packets</li>
* <li>Send a BYE packet</li>
* </ol>
* </p>
* <p>
* IMPORTANT NOTE ABOUT TIMEOUTS:
* A client is considered to be timed out if XBMC doesn't received a packet
* at least once every 60 seconds. To "ping" XBMC with an empty packet use
* PacketPING or XBMCClient.ping(). See the documentation for details.
* </p>
* <p>
* Base class that implements a single event packet.
* - Generic packet structure (maximum 1024 bytes per packet)
* - Header is 32 bytes long, so 992 bytes available for payload
* - large payloads can be split into multiple packets using H4 and H5
* H5 should contain total no. of packets in such a case
* - H6 contains length of P1, which is limited to 992 bytes
* - if H5 is 0 or 1, then H4 will be ignored (single packet msg)
* - H7 must be set to zeros for now
* </p>
* <pre>
* -----------------------------
* | -H1 Signature ("XBMC") | - 4 x CHAR 4B
* | -H2 Version (eg. 2.0) | - 2 x UNSIGNED CHAR 2B
* | -H3 PacketType | - 1 x UNSIGNED SHORT 2B
* | -H4 Sequence number | - 1 x UNSIGNED LONG 4B
* | -H5 No. of packets in msg | - 1 x UNSIGNED LONG 4B
* | -H6 Payloadsize of packet | - 1 x UNSIGNED SHORT 2B
* | -H7 Client's unique token | - 1 x UNSIGNED LONG 4B
* | -H8 Reserved | - 10 x UNSIGNED CHAR 10B
* |---------------------------|
* | -P1 payload | -
* -----------------------------
* </pre>
* @author Stefan Agner
*
*/
public abstract class Packet {
private final byte[] sig;
private byte[] payload = new byte[0];
private final byte minver;
private final byte majver;
private final short packettype;
private final static short MAX_PACKET_SIZE = 1024;
private final static short HEADER_SIZE = 32;
private final static short MAX_PAYLOAD_SIZE = MAX_PACKET_SIZE - HEADER_SIZE;
protected final static byte PT_HELO = 0x01;
protected final static byte PT_BYE = 0x02;
protected final static byte PT_BUTTON = 0x03;
protected final static byte PT_MOUSE = 0x04;
protected final static byte PT_PING = 0x05;
protected final static byte PT_BROADCAST = 0x06;
protected final static byte PT_NOTIFICATION = 0x07;
protected final static byte PT_BLOB = 0x08;
protected final static byte PT_LOG = 0x09;
protected final static byte PT_ACTION = 0x0A;
protected final static byte PT_DEBUG = (byte)0xFF;
public final static byte ICON_NONE = 0x00;
public final static byte ICON_JPEG = 0x01;
public final static byte ICON_PNG = 0x02;
public final static byte ICON_GIF = 0x03;
private static final int uid = (int)(Math.random() * Integer.MAX_VALUE);
/**
* This is an Abstract class and cannot be instanced. Please use one of the Packet implementation Classes
* (PacketXXX).
*
* Implements an XBMC Event Client Packet. Type is to be specified at creation time, Payload can be added
* with the various appendPayload methods. Packet can be sent through UDP-Socket with method "send".
* @param packettype Type of Packet (PT_XXX)
*/
protected Packet(short packettype)
{
sig = new byte[] {'X', 'B', 'M', 'C' };
minver = 0;
majver = 2;
this.packettype = packettype;
}
/**
* Appends a String to the payload (terminated with 0x00)
* @param payload Payload as String
*/
protected void appendPayload(String payload)
{
byte[] payloadarr = payload.getBytes();
int oldpayloadsize = this.payload.length;
byte[] oldpayload = this.payload;
this.payload = new byte[oldpayloadsize+payloadarr.length+1]; // Create new Array with more place (+1 for string terminator)
System.arraycopy(oldpayload, 0, this.payload, 0, oldpayloadsize);
System.arraycopy(payloadarr, 0, this.payload, oldpayloadsize, payloadarr.length);
}
/**
* Appends a single Byte to the payload
* @param payload Payload
*/
protected void appendPayload(byte payload)
{
appendPayload(new byte[] { payload });
}
/**
* Appends a Byte-Array to the payload
* @param payloadarr Payload
*/
protected void appendPayload(byte[] payloadarr)
{
int oldpayloadsize = this.payload.length;
byte[] oldpayload = this.payload;
this.payload = new byte[oldpayloadsize+payloadarr.length];
System.arraycopy(oldpayload, 0, this.payload, 0, oldpayloadsize);
System.arraycopy(payloadarr, 0, this.payload, oldpayloadsize, payloadarr.length);
}
/**
* Appends an integer to the payload
* @param i Payload
*/
protected void appendPayload(int i) {
appendPayload(intToByteArray(i));
}
/**
* Appends a short to the payload
* @param s Payload
*/
protected void appendPayload(short s) {
appendPayload(shortToByteArray(s));
}
/**
* Get Number of Packets which will be sent with current Payload...
* @return Number of Packets
*/
public int getNumPackets()
{
// return (payload.length + (MAX_PAYLOAD_SIZE - 1)) / MAX_PAYLOAD_SIZE;
return 1 + Math.max(payload.length - 1, 0) / MAX_PAYLOAD_SIZE;
}
/**
* Get Header for a specific Packet in this sequence...
* @param seq Current sequence number
* @param maxseq Maximal sequence number
* @param actpayloadsize Payloadsize of this packet
* @return Byte-Array with Header information (currently 32-Byte long, see HEADER_SIZE)
*/
private byte[] getHeader(int seq, int maxseq, short actpayloadsize)
{
byte[] header = new byte[HEADER_SIZE];
System.arraycopy(sig, 0, header, 0, 4);
header[4] = majver;
header[5] = minver;
byte[] packettypearr = shortToByteArray(this.packettype);
System.arraycopy(packettypearr, 0, header, 6, 2);
byte[] seqarr = intToByteArray(seq);
System.arraycopy(seqarr, 0, header, 8, 4);
byte[] maxseqarr = intToByteArray(maxseq);
System.arraycopy(maxseqarr, 0, header, 12, 4);
byte[] payloadsize = shortToByteArray(actpayloadsize);
System.arraycopy(payloadsize, 0, header, 16, 2);
byte[] uid = intToByteArray(Packet.uid);
System.arraycopy(uid, 0, header, 18, 4);
byte[] reserved = new byte[10];
System.arraycopy(reserved, 0, header, 22, 10);
return header;
}
/**
* Generates the whole UDP-Message with Header and Payload of a specific Packet in sequence
* @param seq Current sequence number
* @return Byte-Array with UDP-Message
*/
private byte[] getUDPMessage(int seq)
{
int maxseq = getNumPackets();
if(seq > maxseq)
return null;
short actpayloadsize;
if(seq == maxseq)
actpayloadsize = (short)((payload.length - 1) % MAX_PAYLOAD_SIZE + 1);
else
actpayloadsize = MAX_PAYLOAD_SIZE;
byte[] pack = new byte[HEADER_SIZE+actpayloadsize];
System.arraycopy(getHeader(seq, maxseq, actpayloadsize), 0, pack, 0, HEADER_SIZE);
System.arraycopy(payload, (seq-1)*MAX_PAYLOAD_SIZE, pack, HEADER_SIZE, actpayloadsize);
return pack;
}
/**
* Sends this packet to the EventServer
* @param adr Address of the EventServer
* @param port Port of the EventServer
*/
public void send(InetAddress adr, int port) throws IOException
{
int maxseq = getNumPackets();
DatagramSocket s = new DatagramSocket();
// For each Packet in Sequence...
for(int seq=1;seq<=maxseq;seq++)
{
// Get Message and send them...
byte[] pack = getUDPMessage(seq);
if (pack == null) continue;
DatagramPacket p = new DatagramPacket(pack, pack.length);
p.setAddress(adr);
p.setPort(port);
s.send(p);
}
s.close();
}
/**
* Helper Method to convert an integer to a Byte array
* @param value Value
* @return Byte-Array
*/
private static byte[] intToByteArray(int value) {
return new byte[] {
(byte)(value >>> 24),
(byte)(value >>> 16),
(byte)(value >>> 8),
(byte)value};
}
/**
* Helper Method to convert an short to a Byte array
* @param value Value
* @return Byte-Array
*/
private static byte[] shortToByteArray(short value) {
return new byte[] {
(byte)(value >>> 8),
(byte)value};
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* An ACTION packet tells XBMC to do the action specified, based on the type it knows were it needs to be sent.
* The idea is that this will be as in scripting/skining and keymapping, just triggered from afar.
* @author Stefan Agner
*
*/
public class PacketACTION extends Packet {
public final static byte ACTION_EXECBUILTIN = 0x01;
public final static byte ACTION_BUTTON = 0x02;
/**
* An ACTION packet tells XBMC to do the action specified, based on the type it knows were it needs to be sent.
* @param actionmessage Actionmessage (as in scripting/skinning)
*/
public PacketACTION(String actionmessage)
{
super(PT_ACTION);
byte actiontype = ACTION_EXECBUILTIN;
appendPayload(actionmessage, actiontype);
}
/**
* An ACTION packet tells XBMC to do the action specified, based on the type it knows were it needs to be sent.
* @param actionmessage Actionmessage (as in scripting/skinning)
* @param actiontype Actiontype (ACTION_EXECBUILTIN or ACTION_BUTTON)
*/
public PacketACTION(String actionmessage, byte actiontype)
{
super(PT_ACTION);
appendPayload(actionmessage, actiontype);
}
private void appendPayload(String actionmessage, byte actiontype)
{
appendPayload(actiontype);
appendPayload(actionmessage);
}
}

View file

@ -0,0 +1,158 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
* <p>
* A button packet send a key press or release event to XBMC
*
* @author Stefan Agner
*/
public class PacketBUTTON extends Packet {
protected final static byte BT_USE_NAME = 0x01;
protected final static byte BT_DOWN = 0x02;
protected final static byte BT_UP = 0x04;
protected final static byte BT_USE_AMOUNT = 0x08;
protected final static byte BT_QUEUE = 0x10;
protected final static byte BT_NO_REPEAT = 0x20;
protected final static byte BT_VKEY = 0x40;
protected final static byte BT_AXIS = (byte)0x80;
protected final static byte BT_AXISSINGLE = (byte)0x100;
/**
* A button packet send a key press or release event to XBMC
*
* @param code raw button code (default: 0)
* @param repeat this key press should repeat until released (default: 1)
* Note that queued pressed cannot repeat.
* @param down if this is 1, it implies a press event, 0 implies a release
* event. (default: 1)
* @param queue a queued key press means that the button event is
* executed just once after which the next key press is processed.
* It can be used for macros. Currently there is no support for
* time delays between queued presses. (default: 0)
* @param amount unimplemented for now; in the future it will be used for
* specifying magnitude of analog key press events
* @param axis Axis
*/
public PacketBUTTON(short code, boolean repeat, boolean down, boolean queue, short amount, byte axis) {
super(PT_BUTTON);
String map_name = "";
String button_name = "";
short flags = 0;
appendPayload(code, map_name, button_name, repeat, down, queue, amount, axis, flags);
}
/**
* A button packet send a key press or release event to XBMC
*
* @param map_name a combination of map_name and button_name refers to a
* mapping in the user's Keymap.xml or Lircmap.xml.
* map_name can be one of the following:
* <ul>
* <li>"KB" => standard keyboard map ( <keyboard> section )</li>
* <li>"XG" => xbox gamepad map ( <gamepad> section )</li>
* <li>"R1" => xbox remote map ( <remote> section )</li>
* <li>"R2" => xbox universal remote map ( <universalremote> section )</li>
* <li>"LI:devicename" => LIRC remote map where 'devicename' is the
* actual device's name</li></ul>
* @param button_name a button name defined in the map specified in map_name.
* For example, if map_name is "KB" refering to the <keyboard> section in Keymap.xml
* then, valid button_names include "printscreen", "minus", "x", etc.
* @param repeat this key press should repeat until released (default: 1)
* Note that queued pressed cannot repeat.
* @param down if this is 1, it implies a press event, 0 implies a release
* event. (default: 1)
* @param queue a queued key press means that the button event is
* executed just once after which the next key press is processed.
* It can be used for macros. Currently there is no support for
* time delays between queued presses. (default: 0)
* @param amount unimplemented for now; in the future it will be used for
* specifying magnitude of analog key press events
* @param axis Axis
*/
public PacketBUTTON(String map_name, String button_name, boolean repeat, boolean down, boolean queue, short amount, byte axis) {
super(PT_BUTTON);
short code = 0;
short flags = BT_USE_NAME;
appendPayload(code, map_name, button_name, repeat, down, queue, amount, axis, flags);
}
/**
* Appends Payload for a Button Packet (this method is used by the different Constructors of this Packet)
*
* @param code raw button code (default: 0)
* @param map_name a combination of map_name and button_name refers to a
* mapping in the user's Keymap.xml or Lircmap.xml.
* map_name can be one of the following:
* <ul>
* <li>"KB" => standard keyboard map ( <keyboard> section )</li>
* <li>"XG" => xbox gamepad map ( <gamepad> section )</li>
* <li>"R1" => xbox remote map ( <remote> section )</li>
* <li>"R2" => xbox universal remote map ( <universalremote> section )</li>
* <li>"LI:devicename" => LIRC remote map where 'devicename' is the
* actual device's name</li></ul>
* @param button_name a button name defined in the map specified in map_name.
* For example, if map_name is "KB" refering to the <keyboard> section in Keymap.xml
* then, valid button_names include "printscreen", "minus", "x", etc.
* @param repeat this key press should repeat until released (default: 1)
* Note that queued pressed cannot repeat.
* @param down if this is 1, it implies a press event, 0 implies a release
* event. (default: 1)
* @param queue a queued key press means that the button event is
* executed just once after which the next key press is processed.
* It can be used for macros. Currently there is no support for
* time delays between queued presses. (default: 0)
* @param amount unimplemented for now; in the future it will be used for
* specifying magnitude of analog key press events
* @param axis Axis
* @param flags Packet specific flags
*/
private void appendPayload(short code, String map_name, String button_name, boolean repeat, boolean down, boolean queue, short amount, byte axis, short flags) {
if (amount > 0)
flags |= BT_USE_AMOUNT;
else
amount = 0;
if (down)
flags |= BT_DOWN;
else
flags |= BT_UP;
if (!repeat)
flags |= BT_NO_REPEAT;
if (queue)
flags |= BT_QUEUE;
if (axis == 1)
flags |= BT_AXISSINGLE;
else if (axis == 2)
flags |= BT_AXIS;
appendPayload(code);
appendPayload(flags);
appendPayload(amount);
appendPayload(map_name);
appendPayload(button_name);
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* A BYE packet terminates the connection to XBMC.
* @author Stefan Agner
*
*/
public class PacketBYE extends Packet
{
/**
* A BYE packet terminates the connection to XBMC.
*/
public PacketBYE()
{
super(PT_BYE);
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* A HELO packet establishes a valid connection to XBMC. It is the
* first packet that should be sent.
* @author Stefan Agner
*
*/
public class PacketHELO extends Packet {
/**
* A HELO packet establishes a valid connection to XBMC.
* @param devicename Name of the device which connects to XBMC
*/
public PacketHELO(String devicename)
{
super(PT_HELO);
this.appendPayload(devicename);
this.appendPayload(ICON_NONE);
this.appendPayload((short)0); // port no
this.appendPayload(0); // reserved1
this.appendPayload(0); // reserved2
}
/**
* A HELO packet establishes a valid connection to XBMC.
* @param devicename Name of the device which connects to XBMC
* @param iconType Type of the icon (Packet.ICON_PNG, Packet.ICON_JPEG or Packet.ICON_GIF)
* @param iconData The icon as a Byte-Array
*/
public PacketHELO(String devicename, byte iconType, byte[] iconData)
{
super(PT_HELO);
this.appendPayload(devicename);
this.appendPayload(iconType);
this.appendPayload((short)0); // port no
this.appendPayload(0); // reserved1
this.appendPayload(0); // reserved2
this.appendPayload(iconData); // reserved2
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* A LOG packet tells XBMC to log the message to xbmc.log with the loglevel as specified.
* @author Stefan Agner
*
*/
public class PacketLOG extends Packet {
/**
* A LOG packet tells XBMC to log the message to xbmc.log with the loglevel as specified.
* @param loglevel the loglevel, follows XBMC standard.
* <ul>
* <li>0 = DEBUG</li>
* <li>1 = INFO</li>
* <li>2 = NOTICE</li>
* <li>3 = WARNING</li>
* <li>4 = ERROR</li>
* <li>5 = SEVERE</li>
* </ul>
* @param logmessage the message to log
*/
public PacketLOG(byte loglevel, String logmessage)
{
super(PT_LOG);
appendPayload(loglevel);
appendPayload(logmessage);
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* A MOUSE packets sets the mouse position in XBMC
* @author Stefan Agner
*
*/
public class PacketMOUSE extends Packet {
protected final static byte MS_ABSOLUTE = 0x01;
/**
* A MOUSE packets sets the mouse position in XBMC
* @param x horitontal position ranging from 0 to 65535
* @param y vertical position ranging from 0 to 65535
*/
public PacketMOUSE(int x, int y)
{
super(PT_MOUSE);
byte flags = 0;
flags |= MS_ABSOLUTE;
appendPayload(flags);
appendPayload((short)x);
appendPayload((short)y);
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* This packet displays a notification window in XBMC. It can contain
* a caption, a message and an icon.
* @author Stefan Agner
*
*/
public class PacketNOTIFICATION extends Packet {
/**
* This packet displays a notification window in XBMC.
* @param title Message title
* @param message The actual message
* @param iconType Type of the icon (Packet.ICON_PNG, Packet.ICON_JPEG or Packet.ICON_GIF)
* @param iconData The icon as a Byte-Array
*/
public PacketNOTIFICATION(String title, String message, byte iconType, byte[] iconData)
{
super(PT_NOTIFICATION);
appendPayload(title, message, iconType, iconData);
}
/**
* This packet displays a notification window in XBMC.
* @param title Message title
* @param message The actual message
*/
public PacketNOTIFICATION(String title, String message)
{
super(PT_NOTIFICATION);
appendPayload(title, message, Packet.ICON_NONE, null);
}
/**
* Appends the payload to the packet...
* @param title Message title
* @param message The actual message
* @param iconType Type of the icon (Packet.ICON_PNG, Packet.ICON_JPEG or Packet.ICON_GIF)
* @param iconData The icon as a Byte-Array
*/
private void appendPayload(String title, String message, byte iconType, byte[] iconData)
{
appendPayload(title);
appendPayload(message);
appendPayload(iconType);
appendPayload(0); // reserved
if(iconData!=null)
appendPayload(iconData);
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 2008-2013 Team XBMC
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package org.xbmc.kore.eventclient;
/**
* XBMC Event Client Class
*
* A PING packet tells XBMC that the client is still alive. All valid
* packets act as ping (not just this one). A client needs to ping
* XBMC at least once in 60 seconds or it will time
* @author Stefan Agner
*
*/
public class PacketPING extends Packet {
/**
* A PING packet tells XBMC that the client is still alive.
*/
public PacketPING()
{
super(PT_PING);
}
}

View file

@ -0,0 +1,64 @@
package org.xbmc.kore.host;
import android.os.Handler;
import org.xbmc.kore.jsonrpc.ApiCallback;
import org.xbmc.kore.jsonrpc.ApiException;
import org.xbmc.kore.jsonrpc.ApiMethod;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
/**
* Superclass that facilitates the execution of composite actions, ie sequence of calls to
* {@link org.xbmc.kore.jsonrpc.ApiMethod}, on Kodi, tp be done in a synchronous way, without using callbacks on each
* call and globally handling errors.
* The goal is to be able to call methods on Kodi using {@link HostConnection#execute(ApiMethod)}, getting back the
* future and immediatelly await the result of its completion by calling its {@link Future#get()} method, handling any
* errors in a global try/catch block.
* This is not a major abstraction, just a helper class that allows for client code to be written similarly to a single
* call to {@link HostConnection#execute(ApiMethod, ApiCallback, Handler)} but where the called method is composite.
*
* Subclasses should implement the abstract method {@link HostCompositeAction#execInBackground()} with the specific
* logic that is meant to be executed, knowing that it will be executed in a background thread, thereby allowing
* the use of {@link HostConnection#execute(ApiMethod)} and awaiting on the resulting {@link Future#get()}.
*
* Clients should call {@link HostCompositeAction#execute(HostConnection, ApiCallback, Handler)}, which creates a
* background thread, calls runInBackground and sends the result to the given callback.
*/
public abstract class HostCompositeAction<T> {
protected HostConnection hostConnection;
/**
* Composite action to be executed synchronously
* @return result
*/
public abstract T execInBackground() throws ExecutionException, InterruptedException;
/**
* Calls {@link HostCompositeAction#execInBackground()} in a background thread, and posts the result through the
* given callback on the specified handler
*
* @param hostConnection Host connection on which to call the method
* @param callback Callbacks to post the response to
* @param handler Handler to invoke callbacks on
*/
public void execute(HostConnection hostConnection, ApiCallback<T> callback, Handler handler) {
this.hostConnection = hostConnection;
// Just a protection
if (hostConnection == null) return;
hostConnection.getExecutorService().execute(() -> {
try {
T result = execInBackground();
handler.post(() -> callback.onSuccess(result));
} catch (ExecutionException e) {
handler.post(() -> callback.onError(ApiException.API_ERROR, e.getMessage()));
} catch (InterruptedException e) {
handler.post(() -> callback.onError(ApiException.API_WAITING_ON_RESULT_INTERRUPTED, e.getMessage()));
}
});
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,389 @@
/*
* Copyright 2015 Synced Synapse. 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 org.xbmc.kore.host;
import org.xbmc.kore.utils.LogUtils;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* XBMC Host information container.
*/
public class HostInfo {
private static final String TAG = LogUtils.makeLogTag(HostInfo.class);
private static final String JSON_RPC_ENDPOINT = "/jsonrpc";
/**
* Default HTTPS port
*/
public static final int DEFAULT_HTTPS_PORT = 443;
/**
* Default HTTP port for XBMC (80 on Windows, 8080 on others)
*/
public static final int DEFAULT_HTTP_PORT = 8080;
/**
* Default TCP port for XBMC
*/
public static final int DEFAULT_TCP_PORT = 9090;
/**
* Default WoL port
*/
public static final int DEFAULT_WOL_PORT = 9;
/**
* Default EventServer port for Kodi
*/
public static final int DEFAULT_EVENT_SERVER_PORT = 9777;
public static final int KODI_V12_FRODO = 12;
public static final int KODI_V13_GOTHAM = 13;
public static final int KODI_V14_HELIX = 14;
public static final int KODI_V15_ISENGARD = 15;
public static final int KODI_V16_JARVIS = 16;
public static final int KODI_V17_KRYPTON = 17;
public static final int KODI_V18_LEIA = 18;
public static final int KODI_V19_MATRIX = 19;
public static final int KODI_V20_NEXUS = 20;
public static final int DEFAULT_KODI_VERSION_MAJOR = KODI_V16_JARVIS;
public static final int DEFAULT_KODI_VERSION_MINOR = 1;
public static final String DEFAULT_KODI_VERSION_REVISION = "Unknown";
public static final String DEFAULT_KODI_VERSION_TAG = "stable";
public static Map<Integer, String> versionNames = new HashMap<>();
static {
versionNames.put(KODI_V12_FRODO, "Frodo");
versionNames.put(KODI_V13_GOTHAM, "Gotham");
versionNames.put(KODI_V14_HELIX, "Helix");
versionNames.put(KODI_V15_ISENGARD, "Isengard");
versionNames.put(KODI_V16_JARVIS, "Jarvis");
versionNames.put(KODI_V17_KRYPTON, "Kripton");
versionNames.put(KODI_V18_LEIA, "Leia");
versionNames.put(KODI_V19_MATRIX, "Matrix");
versionNames.put(KODI_V20_NEXUS, "Nexus");
}
/**
* Internal id of the host
*/
private final int id;
/**
* Friendly name of the host
*/
private final String name;
/**
* Connection information
*/
private final String address;
private final int httpPort;
private final int tcpPort;
public final boolean isHttps;
private boolean useEventServer;
private final int eventServerPort;
/**
* Authentication information
*/
private final String username;
private final String password;
/**
* Mac address and Wake On Lan port
*/
private String macAddress;
private int wolPort;
/**
* Direct share target
*/
private boolean showAsDirectShareTarget;
/**
* Prefered protocol to communicate with this host
*/
private int protocol;
/**
* Kodi Version
*/
private int kodiVersionMajor;
private int kodiVersionMinor;
private String kodiVersionRevision;
private String kodiVersionTag;
/**
* Last time updated (in millis)
*/
private final long updated;
private final String auxImageHttpAddress;
/**
* Full constructor. This constructor should be used when instantiating from the database
*
* @param name Friendly name of the host
* @param id ID
* @param address URL
* @param protocol Protocol
* @param httpPort HTTP Port
* @param tcpPort TCP Port
* @param username Username for basic auth
* @param password Password for basic auth
*/
public HostInfo(int id, String name, String address, int protocol, int httpPort, int tcpPort,
String username, String password, String macAddress, int wolPort, boolean showAsDirectShareTarget,
boolean useEventServer, int eventServerPort,
int kodiVersionMajor, int kodiVersionMinor, String kodiVersionRevision, String kodiVersionTag,
long updated, boolean isHttps) {
this.id = id;
this.name = name;
this.address = address;
if (!HostConnection.isValidProtocol(protocol)) {
throw new IllegalArgumentException("Invalid protocol specified.");
}
this.protocol = protocol;
this.httpPort = httpPort;
this.isHttps = isHttps;
this.tcpPort = tcpPort;
this.username = username;
this.password = password;
this.macAddress = macAddress;
this.wolPort = wolPort;
this.showAsDirectShareTarget = showAsDirectShareTarget;
this.useEventServer = useEventServer;
this.eventServerPort = eventServerPort;
this.kodiVersionMajor = kodiVersionMajor;
this.kodiVersionMinor = kodiVersionMinor;
this.kodiVersionRevision = kodiVersionRevision;
this.kodiVersionTag = kodiVersionTag;
this.updated = updated;
// For performance reasons
this.auxImageHttpAddress = getHttpURL() + "/image/";
}
/**
* Auxiliary constructor for HTTP protocol.
* This constructor should only be used to test connections. It doesn't represent an
* instance of the host in the database.
*
* @param name Friendly name of the host
* @param address URL
* @param httpPort HTTP Port
* @param username Username for basic auth
* @param password Password for basic auth
*/
public HostInfo(String name, String address, int protocol, int httpPort,
int tcpPort, String username, String password,
boolean useEventServer, int eventServerPort, boolean isHttps,
boolean showAsDirectShareTarget) {
this(-1, name, address, protocol, httpPort, tcpPort, username,
password, null, DEFAULT_WOL_PORT, showAsDirectShareTarget, useEventServer,
eventServerPort, DEFAULT_KODI_VERSION_MAJOR, DEFAULT_KODI_VERSION_MINOR,
DEFAULT_KODI_VERSION_REVISION, DEFAULT_KODI_VERSION_TAG,
0, isHttps);
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public String getAddress() {
return address;
}
public int getHttpPort() {
return httpPort;
}
public int getTcpPort() {
return tcpPort;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getMacAddress() {
return macAddress;
}
public void setMacAddress(String macAddress) {
this.macAddress = macAddress;
}
public int getWolPort() {
return wolPort;
}
public void setWolPort(int wolPort) {
this.wolPort = wolPort;
}
public boolean getShowAsDirectShareTarget() {
return showAsDirectShareTarget;
}
public void setShowAsDirectShareTarget(boolean showAsDirectShareTarget) {
this.showAsDirectShareTarget = showAsDirectShareTarget;
}
public int getProtocol() {
return protocol;
}
public boolean getUseEventServer() {
return useEventServer;
}
public int getEventServerPort() {
return eventServerPort;
}
public int getKodiVersionMajor() {
return kodiVersionMajor;
}
public int getKodiVersionMinor() {
return kodiVersionMinor;
}
public String getKodiVersionRevision() {
return kodiVersionRevision;
}
public String getKodiVersionTag() {
return kodiVersionTag;
}
public String getKodiVersionDesc() {
if (versionNames.containsKey(kodiVersionMajor)) {
return String.format(Locale.getDefault(), "%s (%d.%d)", versionNames.get(kodiVersionMajor), kodiVersionMajor, kodiVersionMinor);
} else {
return String.format(Locale.getDefault(), "%d.%d", kodiVersionMajor, kodiVersionMinor);
}
}
public long getUpdated() {
return updated;
}
/**
* Overrides the protocol for this host info
* @param protocol Protocol
*/
public void setProtocol(int protocol) {
if (!HostConnection.isValidProtocol(protocol)) {
throw new IllegalArgumentException("Invalid protocol specified.");
}
this.protocol = protocol;
}
/**
* Overrides the use of EventServer
* @param useEventServer Whether to use EventServer
*/
public void setUseEventServer(boolean useEventServer) {
this.useEventServer = useEventServer;
}
public void setKodiVersionMajor(int kodiVersionMajor) {
this.kodiVersionMajor = kodiVersionMajor;
}
public void setKodiVersionMinor(int kodiVersionMinor) {
this.kodiVersionMinor = kodiVersionMinor;
}
public void setKodiVersionRevision(String kodiVersionRevision) {
this.kodiVersionRevision = kodiVersionRevision;
}
public void setKodiVersionTag(String kodiVersionTag) {
this.kodiVersionTag = kodiVersionTag;
}
public boolean isGothamOrLater() {
return kodiVersionMajor >= KODI_V13_GOTHAM;
}
public boolean isKryptonOrLater() {
return kodiVersionMajor >= KODI_V17_KRYPTON;
}
public boolean isLeiaOrLater() {
return kodiVersionMajor >= KODI_V18_LEIA;
}
/**
* Returns the URL of the host
* @return HTTP URL eg. http://192.168.1.1:8080
*/
public String getHttpURL() {
String scheme = isHttps ? "https://" : "http://";
return scheme + address + ":" + httpPort;
}
/**
* Returns the JSON RPC endpoint URL of the host
* @return HTTP URL eg. http://192.168.1.1:8080/jsonrpc
*/
public String getJsonRpcHttpEndpoint() {
return getHttpURL() + JSON_RPC_ENDPOINT;
}
/**
* Get the URL of an image, given the image identifier returned by XBMC
* @param image image identifier stored in XBMC
* @return URL on the XBMC host on which the image can be fetched
*/
public String getImageUrl(String image) {
if (image == null) {
return null;
}
try {
// return getHttpURL() + "/image/" + URLEncoder.encode(image, "UTF-8");
return auxImageHttpAddress + URLEncoder.encode(image, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
// Ignore for now...
return null;
}
}
}

View file

@ -0,0 +1,524 @@
/*
* Copyright 2015 Synced Synapse. 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 org.xbmc.kore.host;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.text.format.DateUtils;
import androidx.annotation.NonNull;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.preference.PreferenceManager;
import com.squareup.picasso.OkHttp3Downloader;
import com.squareup.picasso.Picasso;
import org.xbmc.kore.R;
import org.xbmc.kore.Settings;
import org.xbmc.kore.ShareOpenActivity;
import org.xbmc.kore.jsonrpc.ApiCallback;
import org.xbmc.kore.jsonrpc.method.Application;
import org.xbmc.kore.jsonrpc.type.ApplicationType;
import org.xbmc.kore.provider.MediaContract;
import org.xbmc.kore.utils.LogUtils;
import org.xbmc.kore.utils.NetUtils;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import okhttp3.Cache;
import okhttp3.OkHttpClient;
/**
* Manages XBMC Hosts
* Singleton that loads the list of registered hosts, keeps a
* {@link HostConnection} to the active host
* and allows for creation and removal of hosts
*/
public class HostManager {
private static final String TAG = LogUtils.makeLogTag(HostManager.class);
// Singleton instance
private static volatile HostManager instance = null;
private final Context context;
/**
* Arraylist that will hold all the hosts in the database
*/
private ArrayList<HostInfo> hosts = new ArrayList<>();
/**
* Current host
*/
private HostInfo currentHostInfo = null;
/**
* Current host connection
*/
private HostConnection currentHostConnection = null;
/**
* Picasso to download images from current XBMC
*/
private Picasso currentPicasso = null;
/**
* Current connection observer
*/
private HostConnectionObserver currentHostConnectionObserver = null;
/**
* Singleton constructor
* @param context Context (can pass Activity context, will get App Context)
*/
protected HostManager(Context context) {
this.context = context.getApplicationContext();
}
/**
* Singleton access method
* @param context Android app context
* @return HostManager singleton
*/
public static HostManager getInstance(@NonNull Context context) {
if (instance == null) {
synchronized (HostManager.class) {
if (instance == null) {
instance = new HostManager(context);
}
}
}
return instance;
}
/**
* Returns the current host list
* @return Host list
*/
public ArrayList<HostInfo> getHosts() {
return getHosts(false);
}
/**
* Returns the current host list, maybe forcing a reload from the database
* @param forcedReload Whether to force a reload from the database
* @return Host list
*/
public ArrayList<HostInfo> getHosts(boolean forcedReload) {
if (forcedReload || (hosts.isEmpty())) {
hosts.clear();
Cursor cursor = context.getContentResolver()
.query(MediaContract.Hosts.CONTENT_URI,
MediaContract.Hosts.ALL_COLUMNS,
null, null, null);
if (cursor == null) return hosts;
if (cursor.getCount() > 0) {
while (cursor.moveToNext()) {
int idx = 0;
int id = cursor.getInt(idx++);
long updated = cursor.getLong(idx++);
String name = cursor.getString(idx++);
String address = cursor.getString(idx++);
int protocol = cursor.getInt(idx++);
int httpPort = cursor.getInt(idx++);
int tcpPort = cursor.getInt(idx++);
String username = cursor.getString(idx++);
String password = cursor.getString(idx++);
String macAddress = cursor.getString(idx++);
int wolPort = cursor.getInt(idx++);
boolean directShare = (cursor.getInt(idx++) != 0);
boolean useEventServer = (cursor.getInt(idx++) != 0);
int eventServerPort = cursor.getInt(idx++);
int kodiVersionMajor = cursor.getInt(idx++);
int kodiVersionMinor = cursor.getInt(idx++);
String kodiVersionRevision = cursor.getString(idx++);
String kodiVersionTag = cursor.getString(idx++);
boolean isHttps = (cursor.getInt(idx++) != 0);
hosts.add(new HostInfo(
id, name, address, protocol, httpPort, tcpPort,
username, password, macAddress, wolPort, directShare, useEventServer, eventServerPort,
kodiVersionMajor, kodiVersionMinor, kodiVersionRevision, kodiVersionTag,
updated, isHttps));
}
}
cursor.close();
}
return hosts;
}
/**
* Returns the current active host info
* @return Active host info
*/
public HostInfo getHostInfo() {
if (currentHostInfo == null) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
int currentHostId = prefs.getInt(Settings.KEY_PREF_CURRENT_HOST_ID, Settings.DEFAULT_PREF_CURRENT_HOST_ID);
ArrayList<HostInfo> hosts = getHosts();
// No host selected. Check if there are hosts configured and default to the first one
if (currentHostId == -1) {
if (!hosts.isEmpty()) {
currentHostInfo = hosts.get(0);
currentHostId = currentHostInfo.getId();
prefs.edit()
.putInt(Settings.KEY_PREF_CURRENT_HOST_ID, currentHostId)
.apply();
}
} else {
for (HostInfo host : hosts) {
if (host.getId() == currentHostId) {
currentHostInfo = host;
break;
}
}
}
}
return currentHostInfo;
}
/**
* Returns the current active host connection
* @return Active host connection
*/
public HostConnection getConnection() {
if (currentHostConnection == null) {
synchronized (this) {
if (currentHostConnection == null) {
currentHostInfo = getHostInfo();
if (currentHostInfo != null) {
currentHostConnection = new HostConnection(currentHostInfo);
}
}
}
}
return currentHostConnection;
}
/**
* Returns the current host {@link Picasso} image downloader
* @return {@link Picasso} instance suitable to download images from the current xbmc
*/
public Picasso getPicasso() {
if (currentPicasso == null) {
currentHostInfo = getHostInfo();
if (currentHostInfo != null) {
// currentPicasso = new Picasso.Builder(context)
// .downloader(new BasicAuthUrlConnectionDownloader(context,
// currentHostInfo.getUsername(), currentHostInfo.getPassword()))
// .indicatorsEnabled(BuildConfig.DEBUG)
// .build();
// Create the okHttpCliente, with default timeout, authentication and cache
File cacheDir = NetUtils.createDefaultCacheDir(context);
long cacheSize = NetUtils.calculateDiskCacheSize(cacheDir);
OkHttpClient picassoClient = new OkHttpClient.Builder()
.connectTimeout(getConnection().getConnectTimeout(), TimeUnit.MILLISECONDS)
.authenticator(getConnection().getOkHttpAuthenticator())
.cache(new Cache(cacheDir, cacheSize))
.build();
currentPicasso = new Picasso.Builder(context)
.downloader(new OkHttp3Downloader(picassoClient))
// .indicatorsEnabled(BuildConfig.DEBUG)
.build();
}
}
return currentPicasso;
}
/**
* Returns the current {@link HostConnectionObserver} for the current connection
* @return The {@link HostConnectionObserver} for the current connection
*/
public HostConnectionObserver getHostConnectionObserver() {
if (currentHostConnectionObserver == null) {
currentHostConnection = getConnection();
if (currentHostConnection != null) {
currentHostConnectionObserver = new HostConnectionObserver(currentHostConnection);
}
}
return currentHostConnectionObserver;
}
/**
* Sets the current host.
* @param hostInfo Host info
*/
public void switchHost(HostInfo hostInfo) {
releaseCurrentHost();
currentHostInfo = hostInfo;
if (currentHostInfo != null) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putInt(Settings.KEY_PREF_CURRENT_HOST_ID, currentHostInfo.getId())
.apply();
// Switched host, update dynamic shortcuts to only include the others
updateDynamicShortcuts();
}
}
/**
* Add all kodi hosts, except the current one, to the dynamic shortcuts list
* The current one is always accessible via the default intent filters
*/
private void updateDynamicShortcuts() {
ShortcutManagerCompat.removeAllDynamicShortcuts(context);
ArrayList<HostInfo> hosts = getHosts();
for (HostInfo host : hosts) {
if (host.getId() != currentHostInfo.getId() &&
host.getShowAsDirectShareTarget()) {
String id = Integer.toString(host.getId());
Intent defaultOpenIntent = new Intent(ShareOpenActivity.DEFAULT_OPEN_ACTION)
.setClass(context, ShareOpenActivity.class)
.addCategory(ShareOpenActivity.SHARE_TARGET_CATEGORY)
.putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, id);
ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(context, id)
.setShortLabel(host.getName())
.setLongLabel(host.getName())
.setIcon(IconCompat.createWithResource(context, R.mipmap.ic_launcher))
.setCategories(Collections.singleton(ShareOpenActivity.SHARE_TARGET_CATEGORY))
.setIntent(defaultOpenIntent)
.build();
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut);
}
}
}
/**
* Adds a new XBMC host to the database
* @param hostInfo Host to add
* @return Newly created {@link org.xbmc.kore.host.HostInfo}
*/
public HostInfo addHost(HostInfo hostInfo) {
return addHost(hostInfo.getName(), hostInfo.getAddress(), hostInfo.getProtocol(),
hostInfo.getHttpPort(), hostInfo.getTcpPort(),
hostInfo.getUsername(), hostInfo.getPassword(),
hostInfo.getMacAddress(), hostInfo.getWolPort(),
hostInfo.getShowAsDirectShareTarget(), hostInfo.getUseEventServer(),
hostInfo.getEventServerPort(), hostInfo.getKodiVersionMajor(),
hostInfo.getKodiVersionMinor(), hostInfo.getKodiVersionRevision(),
hostInfo.getKodiVersionTag(), hostInfo.isHttps);
}
/**
* Adds a new XBMC host to the database
* @param name Name of this instance
* @param address Hostname or IP Address
* @param protocol Protocol to use
* @param httpPort HTTP port
* @param tcpPort TCP port
* @param username Username for HTTP
* @param password Password for HTTP
* @return Newly created {@link org.xbmc.kore.host.HostInfo}
*/
public HostInfo addHost(String name, String address, int protocol, int httpPort, int tcpPort,
String username, String password, String macAddress, int wolPort, boolean directShare,
boolean useEventServer, int eventServerPort,
int kodiVersionMajor, int kodiVersionMinor, String kodiVersionRevision, String kodiVersionTag,
boolean isHttps) {
ContentValues values = new ContentValues();
values.put(MediaContract.HostsColumns.NAME, name);
values.put(MediaContract.HostsColumns.ADDRESS, address);
values.put(MediaContract.HostsColumns.PROTOCOL, protocol);
values.put(MediaContract.HostsColumns.HTTP_PORT, httpPort);
values.put(MediaContract.HostsColumns.TCP_PORT, tcpPort);
values.put(MediaContract.HostsColumns.USERNAME, username);
values.put(MediaContract.HostsColumns.PASSWORD, password);
values.put(MediaContract.HostsColumns.MAC_ADDRESS, macAddress);
values.put(MediaContract.HostsColumns.WOL_PORT, wolPort);
values.put(MediaContract.HostsColumns.DIRECT_SHARE, directShare);
values.put(MediaContract.HostsColumns.USE_EVENT_SERVER, useEventServer);
values.put(MediaContract.HostsColumns.EVENT_SERVER_PORT, eventServerPort);
values.put(MediaContract.HostsColumns.KODI_VERSION_MAJOR, kodiVersionMajor);
values.put(MediaContract.HostsColumns.KODI_VERSION_MINOR, kodiVersionMinor);
values.put(MediaContract.HostsColumns.KODI_VERSION_REVISION, kodiVersionRevision);
values.put(MediaContract.HostsColumns.KODI_VERSION_TAG, kodiVersionTag);
values.put(MediaContract.HostsColumns.IS_HTTPS, isHttps);
Uri newUri = context.getContentResolver()
.insert(MediaContract.Hosts.CONTENT_URI, values);
long newId = Long.parseLong(MediaContract.Hosts.getHostId(newUri));
// Refresh the list and return the created host
hosts = getHosts(true);
HostInfo newHost = null;
for (HostInfo host : hosts) {
if (host.getId() == newId) {
newHost = host;
break;
}
}
return newHost;
}
/**
* Edits a host on the database
* @param hostId Id of the host to edit
* @param newHostInfo New values to update
* @return New {@link HostInfo} object
*/
public HostInfo editHost(int hostId, HostInfo newHostInfo) {
ContentValues values = new ContentValues();
values.put(MediaContract.HostsColumns.NAME, newHostInfo.getName());
values.put(MediaContract.HostsColumns.ADDRESS, newHostInfo.getAddress());
values.put(MediaContract.HostsColumns.PROTOCOL, newHostInfo.getProtocol());
values.put(MediaContract.HostsColumns.HTTP_PORT, newHostInfo.getHttpPort());
values.put(MediaContract.HostsColumns.TCP_PORT, newHostInfo.getTcpPort());
values.put(MediaContract.HostsColumns.USERNAME, newHostInfo.getUsername());
values.put(MediaContract.HostsColumns.PASSWORD, newHostInfo.getPassword());
values.put(MediaContract.HostsColumns.MAC_ADDRESS, newHostInfo.getMacAddress());
values.put(MediaContract.HostsColumns.WOL_PORT, newHostInfo.getWolPort());
values.put(MediaContract.HostsColumns.DIRECT_SHARE, newHostInfo.getShowAsDirectShareTarget());
values.put(MediaContract.HostsColumns.USE_EVENT_SERVER, newHostInfo.getUseEventServer());
values.put(MediaContract.HostsColumns.EVENT_SERVER_PORT, newHostInfo.getEventServerPort());
values.put(MediaContract.HostsColumns.KODI_VERSION_MAJOR, newHostInfo.getKodiVersionMajor());
values.put(MediaContract.HostsColumns.KODI_VERSION_MINOR, newHostInfo.getKodiVersionMinor());
values.put(MediaContract.HostsColumns.KODI_VERSION_REVISION, newHostInfo.getKodiVersionRevision());
values.put(MediaContract.HostsColumns.KODI_VERSION_TAG, newHostInfo.getKodiVersionTag());
values.put(MediaContract.HostsColumns.IS_HTTPS, newHostInfo.isHttps);
context.getContentResolver()
.update(MediaContract.Hosts.buildHostUri(hostId), values, null, null);
// Refresh the list and return the created host
hosts = getHosts(true);
HostInfo newHost = null;
for (HostInfo host : hosts) {
if (host.getId() == hostId) {
newHost = host;
break;
}
}
return newHost;
}
/**
* Deletes a host from the database.
* If the delete host is the current one, we will try too change the current one to another
* or set it to null if there's no other
* @param hostId Id of the host to delete
*/
public void deleteHost(final int hostId) {
// Async call delete. The triggers to delete all host information can take some time
new Thread(() -> context.getContentResolver()
.delete(MediaContract.Hosts.buildHostUri(hostId), null, null)).start();
// Refresh information
int index = -1;
for (int i = 0; i < hosts.size(); i++) {
if (hosts.get(i).getId() == hostId) {
index = i;
break;
}
}
if (index != -1)
hosts.remove(index);
// If we just deleted the current connection, switch to another
if ((currentHostInfo != null) && (currentHostInfo.getId() == hostId)) {
releaseCurrentHost();
if (!hosts.isEmpty())
switchHost(hosts.get(0));
}
}
/**
* Releases all state related to the current connection
*/
private void releaseCurrentHost() {
if (currentHostConnectionObserver != null) {
currentHostConnectionObserver.stopObserving();
currentHostConnectionObserver = null;
}
if (currentHostConnection != null) {
currentHostConnection.disconnect();
currentHostConnection = null;
}
if (currentPicasso != null) {
// Calling shutdown here causes a picasso error:
// Handler (com.squareup.picasso.Stats$StatsHandler) {41b13d40} sending message to a Handler on a dead thread
// Check: https://github.com/square/picasso/issues/445
// So, for now, just let it be...
// currentPicasso.shutdown();
currentPicasso = null;
}
}
// Check Kodi's version every 2 hours
private final static long KODI_VERSION_CHECK_INTERVAL_MILLIS = 2 * DateUtils.HOUR_IN_MILLIS;
/**
* Periodic checks Kodi's version and updates the DB to reflect that.
* This should be called somewhere that gets executed periodically
*
*/
public void checkAndUpdateKodiVersion() {
if (currentHostInfo == null) {
currentHostInfo = getHostInfo();
if (currentHostInfo == null) return;
}
if (currentHostInfo.getUpdated() + KODI_VERSION_CHECK_INTERVAL_MILLIS < java.lang.System.currentTimeMillis()) {
LogUtils.LOGD(TAG, "Checking Kodi version...");
final int checkHostId = currentHostInfo.getId();
final Application.GetProperties getProperties = new Application.GetProperties(Application.GetProperties.VERSION);
getProperties.execute(getConnection(), new ApiCallback<ApplicationType.PropertyValue>() {
@Override
public void onSuccess(ApplicationType.PropertyValue result) {
// Simple check to see if we didn't switched host in the meantime.
// Given that this and all calls to switchHost are run on the UI thread, there's no need for more
if (checkHostId != currentHostInfo.getId()) return;
LogUtils.LOGD(TAG, "Successfully checked Kodi version.");
currentHostInfo.setKodiVersionMajor(result.version.major);
currentHostInfo.setKodiVersionMinor(result.version.minor);
currentHostInfo.setKodiVersionRevision(result.version.revision);
currentHostInfo.setKodiVersionTag(result.version.tag);
currentHostInfo = editHost(currentHostInfo.getId(), currentHostInfo);
}
@Override
public void onError(int errorCode, String description) {
// Couldn't get Kodi version... Ignore
LogUtils.LOGD(TAG, "Couldn't get Kodi version. Error: " + description);
}
}, new Handler(Looper.getMainLooper()));
}
}
}

View file

@ -0,0 +1,176 @@
/*
* Copyright 2018 Martijn Brekhof. 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 org.xbmc.kore.host.actions;
import androidx.annotation.Nullable;
import org.xbmc.kore.host.HostCompositeAction;
import org.xbmc.kore.host.HostConnection;
import org.xbmc.kore.jsonrpc.ApiMethod;
import org.xbmc.kore.jsonrpc.method.Playlist;
import org.xbmc.kore.jsonrpc.type.ListType;
import org.xbmc.kore.jsonrpc.type.PlaylistType;
import org.xbmc.kore.utils.LogUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ExecutionException;
/**
* Retrieves the playlist items for the first non-empty playlist or null if no playlists are available.
*/
public class GetPlaylist extends HostCompositeAction<ArrayList<GetPlaylist.GetPlaylistResult>> {
private static final String TAG = LogUtils.makeLogTag(GetPlaylist.class);
private final static String[] propertiesToGet = new String[] {
ListType.FieldsAll.ART,
ListType.FieldsAll.ARTIST,
ListType.FieldsAll.ALBUMARTIST,
ListType.FieldsAll.ALBUM,
ListType.FieldsAll.DISPLAYARTIST,
ListType.FieldsAll.EPISODE,
ListType.FieldsAll.FANART,
ListType.FieldsAll.FILE,
ListType.FieldsAll.SEASON,
ListType.FieldsAll.SHOWTITLE,
ListType.FieldsAll.STUDIO,
ListType.FieldsAll.TAGLINE,
ListType.FieldsAll.THUMBNAIL,
ListType.FieldsAll.TITLE,
ListType.FieldsAll.TRACK,
ListType.FieldsAll.DURATION,
ListType.FieldsAll.RUNTIME,
};
static private HashMap<String, Integer> playlistsTypesAndIds;
private String playlistType;
private int playlistId = -1;
/**
* Use this to get the first non-empty playlist
*/
public GetPlaylist() {}
/**
* Use this to get a playlist for a specific playlist type
* @param playlistType should be one of the types from {@link org.xbmc.kore.jsonrpc.type.PlaylistType.GetPlaylistsReturnType}.
* If null the first non-empty playlist is returned.
*/
public GetPlaylist(String playlistType) {
this.playlistType = playlistType;
}
/**
* Use this to get a playlist for a specific playlist id
* @param playlistId Kodi's playlist id
*/
public GetPlaylist(int playlistId) {
this.playlistId = playlistId;
}
@Override
public ArrayList<GetPlaylistResult> execInBackground() throws ExecutionException, InterruptedException {
if (playlistsTypesAndIds == null)
playlistsTypesAndIds = getPlaylists(hostConnection);
if (playlistType != null) {
GetPlaylistResult getPlaylistResult = retrievePlaylistItemsForType(playlistType);
ArrayList<GetPlaylistResult> playlists = new ArrayList<>();
playlists.add(getPlaylistResult);
return playlists;
} else if (playlistId > -1 ) {
GetPlaylistResult getPlaylistResult = retrievePlaylistItemsForId(playlistId);
ArrayList<GetPlaylistResult> playlists = new ArrayList<>();
playlists.add(getPlaylistResult);
return playlists;
} else
return retrieveNonEmptyPlaylists();
}
private GetPlaylistResult retrievePlaylistItemsForId(int playlistId)
throws InterruptedException, ExecutionException {
List<ListType.ItemsAll> playlistItems = retrievePlaylistItems(hostConnection, playlistId);
return new GetPlaylistResult(playlistId, getPlaylistType(playlistId), playlistItems);
}
private GetPlaylistResult retrievePlaylistItemsForType(String type)
throws InterruptedException, ExecutionException {
Integer id = playlistsTypesAndIds.get(type);
if (id == null) id = -1;
List<ListType.ItemsAll> playlistItems = retrievePlaylistItems(hostConnection, id);
return new GetPlaylistResult(id, type, playlistItems);
}
private ArrayList<GetPlaylistResult> retrieveNonEmptyPlaylists()
throws InterruptedException, ExecutionException {
ArrayList<GetPlaylistResult> playlists = new ArrayList<>();
for (String type : playlistsTypesAndIds.keySet()) {
Integer id = playlistsTypesAndIds.get(type);
if (id == null) id = -1;
List<ListType.ItemsAll> playlistItems = retrievePlaylistItems(hostConnection, id);
if (!playlistItems.isEmpty())
playlists.add(new GetPlaylistResult(id, type, playlistItems));
}
return playlists;
}
private HashMap<String, Integer> getPlaylists(HostConnection hostConnection)
throws ExecutionException, InterruptedException {
HashMap<String, Integer> playlistsHashMap = new HashMap<>();
ArrayList<PlaylistType.GetPlaylistsReturnType> playlistsReturnTypes = hostConnection.execute(new Playlist.GetPlaylists()).get();
for (PlaylistType.GetPlaylistsReturnType type : playlistsReturnTypes) {
playlistsHashMap.put(type.type, type.playlistid);
}
return playlistsHashMap;
}
private List<ListType.ItemsAll> retrievePlaylistItems(HostConnection hostConnection, int playlistId)
throws InterruptedException, ExecutionException {
ApiMethod<List<ListType.ItemsAll>> apiMethod = new Playlist.GetItems(playlistId, propertiesToGet);
return hostConnection.execute(apiMethod).get();
}
private String getPlaylistType(int playlistId) {
for (String key : playlistsTypesAndIds.keySet()) {
Integer id = playlistsTypesAndIds.get(key);
if (id != null && id == playlistId)
return key;
}
return null;
}
public static class GetPlaylistResult {
final public String type;
final public int id;
final public List<ListType.ItemsAll> items;
private GetPlaylistResult(int playlistId, String type, List<ListType.ItemsAll> items) {
this.id = playlistId;
this.type = type;
this.items = items;
}
@Override
public boolean equals(@Nullable Object obj) {
return obj instanceof GetPlaylistResult &&
this.items.equals(((GetPlaylistResult) obj).items);
}
}
}

View file

@ -0,0 +1,110 @@
package org.xbmc.kore.host.actions;
/*
* This file is a part of the Kore project.
*/
import android.content.Context;
import org.xbmc.kore.R;
import org.xbmc.kore.host.HostCompositeAction;
import org.xbmc.kore.host.HostConnection;
import org.xbmc.kore.jsonrpc.method.Player;
import org.xbmc.kore.jsonrpc.method.Playlist;
import org.xbmc.kore.jsonrpc.type.PlayerType;
import org.xbmc.kore.jsonrpc.type.PlaylistType;
import org.xbmc.kore.utils.LogUtils;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
/**
* Opens or queues a video URL on Kodi.
*/
public class OpenSharedUrl extends HostCompositeAction<Boolean> {
private static final String TAG = LogUtils.makeLogTag(OpenSharedUrl.class);
private final Context context;
private final String url;
private final String notificationTitle;
private final String notificationText;
private final boolean queue;
private final int playlistType;
/**
* Creates the composite action
* @param context Context
* @param url The url to play
* @param notificationTitle The title of the notification to be shown when the host is currently playing a video
* @param notificationText The notification to be shown when the host is currently playing a video
* @param queue Whether to open or queue the item
* @param playlistType Playlist type to queue to
*/
public OpenSharedUrl(Context context, String url, String notificationTitle, String notificationText, boolean queue, int playlistType) {
this.context = context;
this.url = url;
this.notificationTitle = notificationTitle;
this.notificationText = notificationText;
this.queue = queue;
this.playlistType = playlistType;
}
/**
* @return whether the host is currently playing a video. If so, the shared url
* is added to the playlist and not played immediately.
* @throws Error when any of the commands sent fails
* @throws InterruptedException when {@code cancel(true)} is called on the resulting
* future while waiting on one of the internal futures.
*/
@Override
public Boolean execInBackground() throws ExecutionException, InterruptedException {
int stage = R.string.error_get_active_player;
try {
List<PlayerType.GetActivePlayersReturnType> players =
hostConnection.execute(new Player.GetActivePlayers())
.get();
boolean mediaIsPlaying = false;
for (PlayerType.GetActivePlayersReturnType player : players) {
if (player.type.equals(PlayerType.GetActivePlayersReturnType.VIDEO)) {
mediaIsPlaying = true;
break;
}
}
stage = R.string.error_queue_media_file;
if (!mediaIsPlaying) {
LogUtils.LOGD(TAG, "Clearing playlist number " + playlistType);
hostConnection.execute(new Playlist.Clear(playlistType))
.get();
}
PlaylistType.Item item = new PlaylistType.Item();
item.file = url;
if (queue) {
// Queue media file to playlist:
LogUtils.LOGD(TAG, "Queueing file");
hostConnection.execute(new Playlist.Add(playlistType, item))
.get();
if (!mediaIsPlaying) {
stage = R.string.error_play_media_file;
hostConnection.execute(new Player.Open(Player.Open.TYPE_PLAYLIST, playlistType))
.get();
} else {
// no get() to ignore the exception that will be thrown by OkHttp
hostConnection.execute(new Player.Notification(notificationTitle, notificationText));
}
} else {
// Don't queue, just play the media file directly:
stage = R.string.error_play_media_file;
hostConnection.execute(new Player.Open(item))
.get();
}
return mediaIsPlaying;
} catch (ExecutionException e) {
throw new ExecutionException(context.getString(stage, e.getMessage()), e.getCause());
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2015 Synced Synapse. 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 org.xbmc.kore.jsonrpc;
import org.xbmc.kore.host.HostConnection;
/**
* Callback from a JSON RPC method execution.
* When executing a method in JSON RPC, through
* {@link HostConnection#execute(ApiMethod, ApiCallback, android.os.Handler)},
* an object implementing this interface should be provided, to call after receiving the response
* from XBMC. Depending on the response {@link ApiCallback#onSuccess(Object)} or {@link
* ApiCallback#onError(int, String)} will be called.
* * @param <T> Result type
*/
public interface ApiCallback<T> {
/**
* Callback that will be called after a sucessfull reponse from the XBMC JSON RPC method
* @param result The result that was obtained and sucessfully parsed from XBMC
*/
void onSuccess(T result);
/**
* Calllback that will be called when an error occurs executing the method on XBMC.
* This can be a general error (like a connection error), or an error reported by XBMC (like
* an incorrect call)
* @param errorCode Error code. Check {@link ApiException} for detailed error codes
* @param description Error description
*/
void onError(int errorCode, String description);
}

View file

@ -0,0 +1,128 @@
/*
* Copyright 2015 Synced Synapse. 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 org.xbmc.kore.jsonrpc;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.xbmc.kore.utils.JsonUtils;
/**
* Exception class for errors on JSON API.
* Some communication exceptions are catched and casted to this type.
* Response error from the JSON API are also returned as an instance of this exception.
*/
public class ApiException extends Exception {
/**
* We got an invalid JSON response
*/
public static final int INVALID_JSON_RESPONSE_FROM_HOST = 0;
/**
* IO Exception while connecting
*/
public static final int IO_EXCEPTION_WHILE_CONNECTING = 1;
/**
* IO Exception while sending
*/
public static final int IO_EXCEPTION_WHILE_SENDING_REQUEST = 2;
/**
* IO Exception while sending
*/
public static final int IO_EXCEPTION_WHILE_READING_RESPONSE = 3;
/**
* HTTP response code unknown/unhandled
*/
public static final int HTTP_RESPONSE_CODE_UNKNOWN = 4;
/**
* HTTP response code unknown/unhandled
*/
public static final int HTTP_RESPONSE_CODE_UNAUTHORIZED = 5;
/**
* HTTP response code unknown/unhandled
*/
public static final int HTTP_RESPONSE_CODE_NOT_FOUND = 6;
/**
*
*/
public static final int HTTP_HOST_URL_INVALID = 7;
/**
* API returned an error
*/
public static int API_ERROR = 100;
/**
* Attempted to send a method while not connected to host
*/
public static int API_NO_CONNECTION = 101;
/**
* Attempted to execute a method with the same id of another already running
*/
public static int API_METHOD_WITH_SAME_ID_ALREADY_EXECUTING = 102;
public static int API_WAITING_ON_RESULT_TIMEDOUT = 103;
public static int API_WAITING_ON_RESULT_INTERRUPTED = 104;
private final int code;
/**
* Constructor
* @param code Exception code
* @param message Message
*/
public ApiException(int code, String message) {
super(message);
this.code = code;
}
/**
* Construct exception from other exception
* @param code Exception code
* @param originalException Original exception
*/
public ApiException(int code, Exception originalException) {
super(originalException);
this.code = code;
}
/**
* Construct exception from JSON response
* @param code Exception code
* @param jsonResponse Json response, with an Error node
*/
public ApiException(int code, ObjectNode jsonResponse) {
super((jsonResponse.get(ApiMethod.ERROR_NODE) != null) ?
JsonUtils.stringFromJsonNode(jsonResponse.get(ApiMethod.ERROR_NODE), "message") :
"No message returned");
this.code = code;
}
/**
* Internal code of the exception
* @return Code of the exception
*/
public int getCode() {
return code;
}
}

View file

@ -0,0 +1,111 @@
package org.xbmc.kore.jsonrpc;
import androidx.annotation.NonNull;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* A Java future implementation, with explicit methods to complete the Future
* <p>
* Don't forget that a call to {@link ApiFuture#get()} blocks the current
* thread until it's unblocked by {@link ApiFuture#cancel(boolean)},
* {@link ApiFuture#complete(Object)} or {@link ApiFuture#completeExceptionally(Throwable)}
*
* @param <T> The type of the result returned by {@link ApiFuture#get()}
*/
public class ApiFuture<T> implements Future<T> {
private enum Status { WAITING, OK, ERROR, CANCELLED }
private final Object lock = new Object();
private Status status = Status.WAITING;
private T ok;
private Throwable error;
public ApiFuture() {}
@Override
public T get() throws InterruptedException, ExecutionException {
try {
return get(0, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("Request timed out. This should not happen when time out is disabled!");
}
}
@Override
public T get(long timeout, @NonNull TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException
{
boolean timed = timeout > 0;
long remaining = unit.toNanos(timeout);
while (true) synchronized (lock) {
switch (status) {
case OK: return ok;
case ERROR: throw new ExecutionException(error);
case CANCELLED: throw new CancellationException();
case WAITING:
if (timed && remaining <= 0) {
throw new TimeoutException();
}
if (!timed) {
lock.wait();
} else {
long start = System.nanoTime();
TimeUnit.NANOSECONDS.timedWait(lock, remaining);
remaining -= System.nanoTime() - start;
}
}
}
}
private boolean setResultAndNotify(Status status, T ok, Throwable error) {
synchronized (lock) {
if (this.status != Status.WAITING) {
return false;
}
this.status = status;
if (status == Status.OK) this.ok = ok;
if (status == Status.ERROR) this.error = error;
this.lock.notifyAll();
return true;
}
}
@Override
public boolean cancel(boolean b) {
return setResultAndNotify(Status.CANCELLED, null, null);
}
@Override
public boolean isCancelled() {
return status == Status.CANCELLED;
}
@Override
public boolean isDone() {
return status != Status.WAITING;
}
/**
* If not already completed, sets the value returned by get() to the given value.
* @param value - the result value
* @return true if this invocation caused this CompletableFuture to transition to a completed state, else false
*/
public boolean complete(T value) {
return setResultAndNotify(Status.OK, value, null);
}
/**
* If not already completed, causes invocations of get() to throw the given exception.
* @param ex = the exception
* @return true if this invocation caused this CompletableFuture to transition to a completed state, else false
*/
public boolean completeExceptionally(Throwable ex) {
return setResultAndNotify(Status.ERROR, null, ex);
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2016 Martijn Brekhof. 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 org.xbmc.kore.jsonrpc;
import org.xbmc.kore.jsonrpc.type.ListType;
import java.util.List;
public class ApiList<T> {
public final List<T> items;
public final ListType.LimitsReturned limits;
public ApiList(List<T> items, ListType.LimitsReturned limits) {
this.items = items;
this.limits = limits;
}
}

Some files were not shown because too many files have changed in this diff Show more