From 480843eb54b1bba0ed3d62fcefdf84cc010b603c Mon Sep 17 00:00:00 2001 From: Gernot Pansy Date: Sat, 17 Oct 2015 00:23:32 +0200 Subject: [PATCH] initial commit --- .gitignore | 7 + app/.gitignore | 1 + app/build.gradle | 33 ++ app/proguard-rules.pro | 17 + app/src/main/AndroidManifest.xml | 65 +++ .../iptv/activity/TvInputSetupActivity.java | 18 + .../java/at/pansy/iptv/domain/Channel.java | 177 ++++++ .../at/pansy/iptv/domain/PlaybackInfo.java | 29 + .../java/at/pansy/iptv/domain/Program.java | 472 ++++++++++++++++ .../iptv/fragment/TvInputSetupFragment.java | 219 ++++++++ .../at/pansy/iptv/player/TvInputPlayer.java | 514 ++++++++++++++++++ .../at/pansy/iptv/service/AccountService.java | 105 ++++ .../at/pansy/iptv/service/SyncService.java | 46 ++ .../at/pansy/iptv/service/TvInputService.java | 404 ++++++++++++++ .../java/at/pansy/iptv/sync/SyncAdapter.java | 298 ++++++++++ .../java/at/pansy/iptv/util/IptvUtil.java | 140 +++++ .../java/at/pansy/iptv/util/RendererUtil.java | 41 ++ .../java/at/pansy/iptv/util/SyncUtil.java | 64 +++ .../at/pansy/iptv/util/TvContractUtil.java | 349 ++++++++++++ .../java/at/pansy/iptv/xmltv/XmlTvParser.java | 392 +++++++++++++ .../res/drawable-mdpi/default_background.xml | 9 + .../main/res/drawable-mdpi/ic_launcher.png | Bin 0 -> 5237 bytes .../main/res/drawable-xhdpi/ic_launcher.png | Bin 0 -> 14383 bytes app/src/main/res/layout/overlay_view.xml | 33 ++ app/src/main/res/layout/setup_activity.xml | 21 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3418 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2206 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4842 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7718 bytes app/src/main/res/values/colors.xml | 18 + app/src/main/res/values/strings.xml | 15 + app/src/main/res/values/styles.xml | 4 + app/src/main/res/values/themes.xml | 12 + app/src/main/res/xml/authenticator.xml | 20 + app/src/main/res/xml/syncadapter.xml | 22 + app/src/main/res/xml/tvinputservice.xml | 18 + build.gradle | 23 + gradle.properties | 18 + settings.gradle | 1 + 39 files changed, 3605 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/at/pansy/iptv/activity/TvInputSetupActivity.java create mode 100644 app/src/main/java/at/pansy/iptv/domain/Channel.java create mode 100644 app/src/main/java/at/pansy/iptv/domain/PlaybackInfo.java create mode 100644 app/src/main/java/at/pansy/iptv/domain/Program.java create mode 100644 app/src/main/java/at/pansy/iptv/fragment/TvInputSetupFragment.java create mode 100644 app/src/main/java/at/pansy/iptv/player/TvInputPlayer.java create mode 100644 app/src/main/java/at/pansy/iptv/service/AccountService.java create mode 100644 app/src/main/java/at/pansy/iptv/service/SyncService.java create mode 100644 app/src/main/java/at/pansy/iptv/service/TvInputService.java create mode 100644 app/src/main/java/at/pansy/iptv/sync/SyncAdapter.java create mode 100644 app/src/main/java/at/pansy/iptv/util/IptvUtil.java create mode 100644 app/src/main/java/at/pansy/iptv/util/RendererUtil.java create mode 100644 app/src/main/java/at/pansy/iptv/util/SyncUtil.java create mode 100644 app/src/main/java/at/pansy/iptv/util/TvContractUtil.java create mode 100644 app/src/main/java/at/pansy/iptv/xmltv/XmlTvParser.java create mode 100644 app/src/main/res/drawable-mdpi/default_background.xml create mode 100644 app/src/main/res/drawable-mdpi/ic_launcher.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/layout/overlay_view.xml create mode 100644 app/src/main/res/layout/setup_activity.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/authenticator.xml create mode 100644 app/src/main/res/xml/syncadapter.xml create mode 100644 app/src/main/res/xml/tvinputservice.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c4de58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..8a47494 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,33 @@ +apply plugin: 'com.android.application' + + +android { + compileSdkVersion 21 + buildToolsVersion "23.0.1" + + defaultConfig { + applicationId "at.pansy.iptv" + minSdkVersion 21 + targetSdkVersion 21 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile 'com.google.android.exoplayer:exoplayer:r1.5.0' +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile 'com.android.support:recyclerview-v7:21.0.3' + compile 'com.android.support:leanback-v17:21.0.3' + compile 'com.android.support:appcompat-v7:21.0.3' + compile 'com.github.bumptech.glide:glide:3.4.+' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e0a3760 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/notz/programs/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: + +# 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 *; +#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f2fa394 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/at/pansy/iptv/activity/TvInputSetupActivity.java b/app/src/main/java/at/pansy/iptv/activity/TvInputSetupActivity.java new file mode 100644 index 0000000..748ae06 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/activity/TvInputSetupActivity.java @@ -0,0 +1,18 @@ +package at.pansy.iptv.activity; + +import android.app.Activity; +import android.os.Bundle; + +import at.pansy.iptv.R; + +/** + * Created by notz. + */ +public class TvInputSetupActivity extends Activity { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.setup_activity); + } +} diff --git a/app/src/main/java/at/pansy/iptv/domain/Channel.java b/app/src/main/java/at/pansy/iptv/domain/Channel.java new file mode 100644 index 0000000..bda34fa --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/domain/Channel.java @@ -0,0 +1,177 @@ +/* + * Copyright 2015 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.domain; + +import android.content.ContentValues; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.text.TextUtils; + +import java.util.Objects; + +/** + * A convenience class to create and insert program information into the database. + */ +public final class Channel implements Comparable { + private static final long INVALID_LONG_VALUE = -1; + + private long channelId; + private String displayName; + private String displayNumber; + private String internalProviderData; + + private Channel() { + channelId = INVALID_LONG_VALUE; + } + + public long getChannelId() { + return channelId; + } + + public String getDisplayName() { + return displayName; + } + + public String getDisplayNumber() { + return displayNumber; + } + + public String getInternalProviderData() { + return internalProviderData; + } + + @Override + public int hashCode() { + return Objects.hash(channelId, displayName, displayNumber, internalProviderData); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Channel)) { + return false; + } + Channel channel = (Channel) other; + return channelId == channel.channelId + && Objects.equals(displayName, channel.displayName) + && Objects.equals(displayNumber, channel.displayNumber) + && Objects.equals(internalProviderData, channel.internalProviderData); + } + + @Override + public int compareTo(Channel other) { + return Long.compare(channelId, other.channelId); + } + + @Override + public String toString() { + return "Channel{" + + "channelId=" + channelId + + ", displayName=" + displayName + + ", displayNumber=" + displayNumber + + ", internalProviderData=" + internalProviderData + + "}"; + } + + public void copyFrom(Channel other) { + if (this == other) { + return; + } + + channelId = other.channelId; + displayName = other.displayName; + displayNumber = other.displayNumber; + internalProviderData = other.internalProviderData; + } + + public ContentValues toContentValues() { + ContentValues values = new ContentValues(); + if (!TextUtils.isEmpty(displayName)) { + values.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName); + } else { + values.putNull(TvContract.Channels.COLUMN_DISPLAY_NAME); + } + if (!TextUtils.isEmpty(displayNumber)) { + values.put(TvContract.Channels.COLUMN_DISPLAY_NUMBER, displayNumber); + } else { + values.putNull(TvContract.Channels.COLUMN_DISPLAY_NUMBER); + } + if (!TextUtils.isEmpty(internalProviderData)) { + values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, internalProviderData); + } else { + values.putNull(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA); + } + return values; + } + + public static Channel fromCursor(Cursor cursor) { + Builder builder = new Builder(); + int index = cursor.getColumnIndex(TvContract.Channels._ID); + if (index >= 0 && !cursor.isNull(index)) { + builder.setChannelId(cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Channels.COLUMN_DISPLAY_NAME); + if (index >= 0 && !cursor.isNull(index)) { + builder.setDisplayName(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Channels.COLUMN_DISPLAY_NUMBER); + if (index >= 0 && !cursor.isNull(index)) { + builder.setDisplayNumber(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA); + if (index >= 0 && !cursor.isNull(index)) { + builder.setInternalProviderData(cursor.getString(index)); + } + return builder.build(); + } + + public static final class Builder { + private final Channel channel; + + public Builder() { + channel = new Channel(); + } + + public Builder(Channel other) { + channel = new Channel(); + channel.copyFrom(other); + } + + public Builder setChannelId(long channelId) { + channel.channelId = channelId; + return this; + } + + public Builder setDisplayName(String displayName) { + channel.displayName = displayName; + return this; + } + + public Builder setDisplayNumber(String displayNumber) { + channel.displayNumber = displayNumber; + return this; + } + + public Builder setInternalProviderData(String data) { + channel.internalProviderData = data; + return this; + } + + public Channel build() { + return channel; + } + } +} diff --git a/app/src/main/java/at/pansy/iptv/domain/PlaybackInfo.java b/app/src/main/java/at/pansy/iptv/domain/PlaybackInfo.java new file mode 100644 index 0000000..c2246d2 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/domain/PlaybackInfo.java @@ -0,0 +1,29 @@ +package at.pansy.iptv.domain; + +import android.media.tv.TvContentRating; + +/** + * Created by notz. + */ +public class PlaybackInfo { + + public static final int VIDEO_TYPE_HTTP_PROGRESSIVE = 0; + public static final int VIDEO_TYPE_HLS = 1; + public static final int VIDEO_TYPE_MPEG_DASH = 2; + public static final int VIDEO_TYPE_OTHER = 3; + + public final long startTimeMs; + public final long endTimeMs; + public final String videoUrl; + public final int videoType; + public final TvContentRating[] contentRatings; + + public PlaybackInfo(long startTimeMs, long endTimeMs, String videoUrl, int videoType, + TvContentRating[] contentRatings) { + this.startTimeMs = startTimeMs; + this.endTimeMs = endTimeMs; + this.contentRatings = contentRatings; + this.videoUrl = videoUrl; + this.videoType = videoType; + } +} diff --git a/app/src/main/java/at/pansy/iptv/domain/Program.java b/app/src/main/java/at/pansy/iptv/domain/Program.java new file mode 100644 index 0000000..953d82d --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/domain/Program.java @@ -0,0 +1,472 @@ +/* + * Copyright 2015 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.domain; + +import android.content.ContentValues; +import android.database.Cursor; +import android.media.tv.TvContentRating; +import android.media.tv.TvContract; +import android.text.TextUtils; + +import java.util.Arrays; +import java.util.Objects; + +import at.pansy.iptv.util.TvContractUtil; + +/** + * A convenience class to create and insert program information into the database. + */ +public final class Program implements Comparable { + + private static final long INVALID_LONG_VALUE = -1; + private static final int INVALID_INT_VALUE = -1; + + private long programId; + private long channelId; + private String title; + private String episodeTitle; + private int seasonNumber; + private int episodeNumber; + private long startTimeUtcMillis; + private long endTimeUtcMillis; + private String description; + private String longDescription; + private int videoWidth; + private int videoHeight; + private String posterArtUri; + private String thumbnailUri; + private String[] canonicalGenres; + private TvContentRating[] contentRatings; + private String internalProviderData; + + private Program() { + channelId = INVALID_LONG_VALUE; + programId = INVALID_LONG_VALUE; + seasonNumber = INVALID_INT_VALUE; + episodeNumber = INVALID_INT_VALUE; + startTimeUtcMillis = INVALID_LONG_VALUE; + endTimeUtcMillis = INVALID_LONG_VALUE; + videoWidth = INVALID_INT_VALUE; + videoHeight = INVALID_INT_VALUE; + } + + public long getProgramId() { + return programId; + } + + public long getChannelId() { + return channelId; + } + + public String getTitle() { + return title; + } + + public String getEpisodeTitle() { + return episodeTitle; + } + + public int getSeasonNumber() { + return seasonNumber; + } + + public int getEpisodeNumber() { + return episodeNumber; + } + + public long getStartTimeUtcMillis() { + return startTimeUtcMillis; + } + + public long getEndTimeUtcMillis() { + return endTimeUtcMillis; + } + + public String getDescription() { + return description; + } + + public String getLongDescription() { + return longDescription; + } + + public int getVideoWidth() { + return videoWidth; + } + + public int getVideoHeight() { + return videoHeight; + } + + public String[] getCanonicalGenres() { + return canonicalGenres; + } + + public TvContentRating[] getContentRatings() { + return contentRatings; + } + + public String getPosterArtUri() { + return posterArtUri; + } + + public String getThumbnailUri() { + return thumbnailUri; + } + + public String getInternalProviderData() { + return internalProviderData; + } + + @Override + public int hashCode() { + return Objects.hash(channelId, startTimeUtcMillis, endTimeUtcMillis, + title, episodeTitle, description, longDescription, videoWidth, videoHeight, + posterArtUri, thumbnailUri, contentRatings, canonicalGenres, seasonNumber, + episodeNumber); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Program)) { + return false; + } + Program program = (Program) other; + return channelId == program.channelId + && startTimeUtcMillis == program.startTimeUtcMillis + && endTimeUtcMillis == program.endTimeUtcMillis + && Objects.equals(title, program.title) + && Objects.equals(episodeTitle, program.episodeTitle) + && Objects.equals(description, program.description) + && Objects.equals(longDescription, program.longDescription) + && videoWidth == program.videoWidth + && videoHeight == program.videoHeight + && Objects.equals(posterArtUri, program.posterArtUri) + && Objects.equals(thumbnailUri, program.thumbnailUri) + && Arrays.equals(contentRatings, program.contentRatings) + && Arrays.equals(canonicalGenres, program.canonicalGenres) + && seasonNumber == program.seasonNumber + && episodeNumber == program.episodeNumber; + } + + @Override + public int compareTo(Program other) { + return Long.compare(startTimeUtcMillis, other.startTimeUtcMillis); + } + + @Override + public String toString() { + return "Program{" + + "programId=" + programId + + ", channelId=" + channelId + + ", title=" + title + + ", episodeTitle=" + episodeTitle + + ", seasonNumber=" + seasonNumber + + ", episodeNumber=" + episodeNumber + + ", startTimeUtcSec=" + startTimeUtcMillis + + ", endTimeUtcSec=" + endTimeUtcMillis + + ", videoWidth=" + videoWidth + + ", videoHeight=" + videoHeight + + ", contentRatings=" + contentRatings + + ", posterArtUri=" + posterArtUri + + ", thumbnailUri=" + thumbnailUri + + ", contentRatings=" + contentRatings + + ", genres=" + canonicalGenres + + "}"; + } + + public void copyFrom(Program other) { + if (this == other) { + return; + } + + programId = other.programId; + channelId = other.channelId; + title = other.title; + episodeTitle = other.episodeTitle; + seasonNumber = other.seasonNumber; + episodeNumber = other.episodeNumber; + startTimeUtcMillis = other.startTimeUtcMillis; + endTimeUtcMillis = other.endTimeUtcMillis; + description = other.description; + longDescription = other.longDescription; + videoWidth = other.videoWidth; + videoHeight = other.videoHeight; + posterArtUri = other.posterArtUri; + thumbnailUri = other.thumbnailUri; + canonicalGenres = other.canonicalGenres; + contentRatings = other.contentRatings; + } + + public ContentValues toContentValues() { + ContentValues values = new ContentValues(); + if (channelId != INVALID_LONG_VALUE) { + values.put(TvContract.Programs.COLUMN_CHANNEL_ID, channelId); + } else { + values.putNull(TvContract.Programs.COLUMN_CHANNEL_ID); + } + if (!TextUtils.isEmpty(title)) { + values.put(TvContract.Programs.COLUMN_TITLE, title); + } else { + values.putNull(TvContract.Programs.COLUMN_TITLE); + } + if (!TextUtils.isEmpty(episodeTitle)) { + values.put(TvContract.Programs.COLUMN_EPISODE_TITLE, episodeTitle); + } else { + values.putNull(TvContract.Programs.COLUMN_EPISODE_TITLE); + } + if (seasonNumber != INVALID_INT_VALUE) { + values.put(TvContract.Programs.COLUMN_SEASON_NUMBER, seasonNumber); + } else { + values.putNull(TvContract.Programs.COLUMN_SEASON_NUMBER); + } + if (episodeNumber != INVALID_INT_VALUE) { + values.put(TvContract.Programs.COLUMN_EPISODE_NUMBER, episodeNumber); + } else { + values.putNull(TvContract.Programs.COLUMN_EPISODE_NUMBER); + } + if (!TextUtils.isEmpty(description)) { + values.put(TvContract.Programs.COLUMN_SHORT_DESCRIPTION, description); + } else { + values.putNull(TvContract.Programs.COLUMN_SHORT_DESCRIPTION); + } + if (!TextUtils.isEmpty(posterArtUri)) { + values.put(TvContract.Programs.COLUMN_POSTER_ART_URI, posterArtUri); + } else { + values.putNull(TvContract.Programs.COLUMN_POSTER_ART_URI); + } + if (!TextUtils.isEmpty(thumbnailUri)) { + values.put(TvContract.Programs.COLUMN_THUMBNAIL_URI, thumbnailUri); + } else { + values.putNull(TvContract.Programs.COLUMN_THUMBNAIL_URI); + } + if (canonicalGenres != null && canonicalGenres.length > 0) { + values.put(TvContract.Programs.COLUMN_CANONICAL_GENRE, + TvContract.Programs.Genres.encode(canonicalGenres)); + } else { + values.putNull(TvContract.Programs.COLUMN_CANONICAL_GENRE); + } + if (contentRatings != null && contentRatings.length > 0) { + values.put(TvContract.Programs.COLUMN_CONTENT_RATING, + TvContractUtil.contentRatingsToString(contentRatings)); + } else { + values.putNull(TvContract.Programs.COLUMN_CONTENT_RATING); + } + if (startTimeUtcMillis != INVALID_LONG_VALUE) { + values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, startTimeUtcMillis); + } else { + values.putNull(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS); + } + if (endTimeUtcMillis != INVALID_LONG_VALUE) { + values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, endTimeUtcMillis); + } else { + values.putNull(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS); + } + if (videoWidth != INVALID_INT_VALUE) { + values.put(TvContract.Programs.COLUMN_VIDEO_WIDTH, videoWidth); + } else { + values.putNull(TvContract.Programs.COLUMN_VIDEO_WIDTH); + } + if (videoHeight != INVALID_INT_VALUE) { + values.put(TvContract.Programs.COLUMN_VIDEO_HEIGHT, videoHeight); + } else { + values.putNull(TvContract.Programs.COLUMN_VIDEO_HEIGHT); + } + if (!TextUtils.isEmpty(internalProviderData)) { + values.put(TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA, internalProviderData); + } else { + values.putNull(TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA); + } + return values; + } + + public static Program fromCursor(Cursor cursor) { + Builder builder = new Builder(); + int index = cursor.getColumnIndex(TvContract.Programs._ID); + if (index >= 0 && !cursor.isNull(index)) { + builder.setProgramId(cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_CHANNEL_ID); + if (index >= 0 && !cursor.isNull(index)) { + builder.setChannelId(cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_TITLE); + if (index >= 0 && !cursor.isNull(index)) { + builder.setTitle(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_EPISODE_TITLE); + if (index >= 0 && !cursor.isNull(index)) { + builder.setEpisodeTitle(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_SEASON_NUMBER); + if(index >= 0 && !cursor.isNull(index)) { + builder.setSeasonNumber(cursor.getInt(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_EPISODE_NUMBER); + if(index >= 0 && !cursor.isNull(index)) { + builder.setEpisodeNumber(cursor.getInt(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_SHORT_DESCRIPTION); + if (index >= 0 && !cursor.isNull(index)) { + builder.setDescription(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_LONG_DESCRIPTION); + if (index >= 0 && !cursor.isNull(index)) { + builder.setLongDescription(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_POSTER_ART_URI); + if (index >= 0 && !cursor.isNull(index)) { + builder.setPosterArtUri(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_THUMBNAIL_URI); + if (index >= 0 && !cursor.isNull(index)) { + builder.setThumbnailUri(cursor.getString(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_CANONICAL_GENRE); + if (index >= 0 && !cursor.isNull(index)) { + builder.setCanonicalGenres(TvContract.Programs.Genres.decode(cursor.getString(index))); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_CONTENT_RATING); + if (index >= 0 && !cursor.isNull(index)) { + builder.setContentRatings(TvContractUtil.stringToContentRatings(cursor.getString( + index))); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS); + if (index >= 0 && !cursor.isNull(index)) { + builder.setStartTimeUtcMillis(cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS); + if (index >= 0 && !cursor.isNull(index)) { + builder.setEndTimeUtcMillis(cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_VIDEO_WIDTH); + if (index >= 0 && !cursor.isNull(index)) { + builder.setVideoWidth((int) cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_VIDEO_HEIGHT); + if (index >= 0 && !cursor.isNull(index)) { + builder.setVideoHeight((int) cursor.getLong(index)); + } + index = cursor.getColumnIndex(TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA); + if (index >= 0 && !cursor.isNull(index)) { + builder.setInternalProviderData(cursor.getString(index)); + } + return builder.build(); + } + + public static final class Builder { + private final Program mProgram; + + public Builder() { + mProgram = new Program(); + } + + public Builder(Program other) { + mProgram = new Program(); + mProgram.copyFrom(other); + } + + public Builder setProgramId(long programId) { + mProgram.programId = programId; + return this; + } + + public Builder setChannelId(long channelId) { + mProgram.channelId = channelId; + return this; + } + + public Builder setTitle(String title) { + mProgram.title = title; + return this; + } + + public Builder setEpisodeTitle(String episodeTitle) { + mProgram.episodeTitle = episodeTitle; + return this; + } + + public Builder setSeasonNumber(int seasonNumber) { + mProgram.seasonNumber = seasonNumber; + return this; + } + + public Builder setEpisodeNumber(int episodeNumber) { + mProgram.episodeNumber = episodeNumber; + return this; + } + + public Builder setStartTimeUtcMillis(long startTimeUtcMillis) { + mProgram.startTimeUtcMillis = startTimeUtcMillis; + return this; + } + + public Builder setEndTimeUtcMillis(long endTimeUtcMillis) { + mProgram.endTimeUtcMillis = endTimeUtcMillis; + return this; + } + + public Builder setDescription(String description) { + mProgram.description = description; + return this; + } + + public Builder setLongDescription(String longDescription) { + mProgram.longDescription = longDescription; + return this; + } + + public Builder setVideoWidth(int width) { + mProgram.videoWidth = width; + return this; + } + + public Builder setVideoHeight(int height) { + mProgram.videoHeight = height; + return this; + } + + public Builder setContentRatings(TvContentRating[] contentRatings) { + mProgram.contentRatings = contentRatings; + return this; + } + + public Builder setPosterArtUri(String posterArtUri) { + mProgram.posterArtUri = posterArtUri; + return this; + } + + public Builder setThumbnailUri(String thumbnailUri) { + mProgram.thumbnailUri = thumbnailUri; + return this; + } + + public Builder setCanonicalGenres(String[] genres) { + mProgram.canonicalGenres = genres; + return this; + } + + public Builder setInternalProviderData(String data) { + mProgram.internalProviderData = data; + return this; + } + + public Program build() { + return mProgram; + } + } +} diff --git a/app/src/main/java/at/pansy/iptv/fragment/TvInputSetupFragment.java b/app/src/main/java/at/pansy/iptv/fragment/TvInputSetupFragment.java new file mode 100644 index 0000000..e1779fc --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/fragment/TvInputSetupFragment.java @@ -0,0 +1,219 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.fragment; + +import android.accounts.Account; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.SyncStatusObserver; +import android.media.tv.TvContract; +import android.media.tv.TvInputInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v17.leanback.app.BackgroundManager; +import android.support.v17.leanback.app.DetailsFragment; +import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.DetailsOverviewRow; +import android.support.v17.leanback.widget.DetailsOverviewRowPresenter; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.widget.Toast; + +import at.pansy.iptv.R; +import at.pansy.iptv.service.AccountService; +import at.pansy.iptv.util.IptvUtil; +import at.pansy.iptv.util.SyncUtil; +import at.pansy.iptv.util.TvContractUtil; +import at.pansy.iptv.xmltv.XmlTvParser; + +/** + * Fragment which shows a sample UI for registering channels and setting up SyncAdapter to + * provide program information in the background. + */ +public class TvInputSetupFragment extends DetailsFragment { + + private static final int ACTION_ADD_CHANNELS = 1; + private static final int ACTION_CANCEL = 2; + private static final int ACTION_IN_PROGRESS = 3; + + private XmlTvParser.TvListing tvListing = null; + private String inputId = null; + + private Action addChannelAction; + private Action inProgressAction; + private ArrayObjectAdapter adapter; + private Object syncObserverHandle; + private boolean syncRequested; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + inputId = getActivity().getIntent().getStringExtra(TvInputInfo.EXTRA_INPUT_ID); + new SetupRowTask().execute(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (syncObserverHandle != null) { + ContentResolver.removeStatusChangeListener(syncObserverHandle); + syncObserverHandle = null; + } + } + + private class SetupRowTask extends AsyncTask { + + @Override + protected Boolean doInBackground(Uri... params) { + tvListing = IptvUtil.getTvListings(getActivity(), + getString(R.string.iptv_ink_channel_url), IptvUtil.FORMAT_M3U); + return tvListing != null; + } + + @Override + protected void onPostExecute(Boolean success) { + if (success) { + initUIs(); + } else { + onError(R.string.feed_error_message); + } + } + + private void initUIs() { + DetailsOverviewRowPresenter dorPresenter = + new DetailsOverviewRowPresenter(new DetailsDescriptionPresenter()); + dorPresenter.setSharedElementEnterTransition(getActivity(), "SetUpFragment"); + + addChannelAction = new Action(ACTION_ADD_CHANNELS, getResources().getString( + R.string.tv_input_setup_add_channel)); + Action cancelAction = new Action(ACTION_CANCEL, + getResources().getString(R.string.tv_input_setup_cancel)); + inProgressAction = new Action(ACTION_IN_PROGRESS, getResources().getString( + R.string.tv_input_setup_in_progress)); + + DetailsOverviewRow row = new DetailsOverviewRow(tvListing); + row.addAction(addChannelAction); + row.addAction(cancelAction); + + ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); + // set detail background and style + dorPresenter.setBackgroundColor(getResources().getColor(R.color.detail_background)); + dorPresenter.setStyleLarge(true); + + dorPresenter.setOnActionClickedListener(new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == ACTION_ADD_CHANNELS) { + setupChannels(inputId); + } else if (action.getId() == ACTION_CANCEL) { + getActivity().finish(); + } + } + }); + + presenterSelector.addClassPresenter(DetailsOverviewRow.class, dorPresenter); + presenterSelector.addClassPresenter(ListRow.class, new ListRowPresenter()); + adapter = new ArrayObjectAdapter(presenterSelector); + adapter.add(row); + + setAdapter(adapter); + + BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity()); + backgroundManager.attach(getActivity().getWindow()); + backgroundManager.setDrawable( + getActivity().getDrawable(R.drawable.default_background)); + } + } + + private void onError(int errorResId) { + Toast.makeText(getActivity(), errorResId, Toast.LENGTH_SHORT).show(); + getActivity().finish(); + } + + private void setupChannels(String inputId) { + if (tvListing == null) { + onError(R.string.feed_error_message); + return; + } + + TvContractUtil.updateChannels(getActivity(), inputId, tvListing.channels); + SyncUtil.setUpPeriodicSync(getActivity(), inputId); + SyncUtil.requestSync(inputId, true); + + syncRequested = true; + // Watch for sync state changes + if (syncObserverHandle == null) { + final int mask = ContentResolver.SYNC_OBSERVER_TYPE_PENDING | + ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE; + syncObserverHandle = ContentResolver.addStatusChangeListener(mask, + mSyncStatusObserver); + } + } + + private class DetailsDescriptionPresenter extends AbstractDetailsDescriptionPresenter { + @Override + protected void onBindDescription(ViewHolder viewHolder, Object item) { + viewHolder.getTitle().setText(R.string.tv_input_label); + } + } + + private final SyncStatusObserver mSyncStatusObserver = new SyncStatusObserver() { + private boolean syncServiceStarted; + private boolean finished; + + @Override + public void onStatusChanged(int which) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + if (finished) { + return; + } + + Account account = AccountService.getAccount(SyncUtil.ACCOUNT_TYPE); + boolean syncActive = ContentResolver.isSyncActive(account, + TvContract.AUTHORITY); + boolean syncPending = ContentResolver.isSyncPending(account, + TvContract.AUTHORITY); + boolean syncServiceInProgress = syncActive || syncPending; + if (syncRequested && syncServiceStarted && !syncServiceInProgress) { + // Only current programs are registered at this point. Request a full sync. + SyncUtil.requestSync(inputId, false); + + getActivity().setResult(Activity.RESULT_OK); + getActivity().finish(); + finished = true; + } + if (!syncServiceStarted && syncServiceInProgress) { + syncServiceStarted = syncServiceInProgress; + DetailsOverviewRow detailRow = (DetailsOverviewRow) adapter.get(0); + detailRow.removeAction(addChannelAction); + detailRow.addAction(0, inProgressAction); + adapter.notifyArrayItemRangeChanged(0, 1); + } + } + }); + } + }; + +} diff --git a/app/src/main/java/at/pansy/iptv/player/TvInputPlayer.java b/app/src/main/java/at/pansy/iptv/player/TvInputPlayer.java new file mode 100644 index 0000000..ebcce46 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/player/TvInputPlayer.java @@ -0,0 +1,514 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package at.pansy.iptv.player; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.media.MediaCodec; +import android.media.tv.TvTrackInfo; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.view.Surface; + +import com.google.android.exoplayer.DefaultLoadControl; +import com.google.android.exoplayer.DummyTrackRenderer; +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.ExoPlayer; +import com.google.android.exoplayer.ExoPlayerLibraryInfo; +import com.google.android.exoplayer.LoadControl; +import com.google.android.exoplayer.MediaCodecAudioTrackRenderer; +import com.google.android.exoplayer.MediaCodecTrackRenderer; +import com.google.android.exoplayer.MediaCodecUtil; +import com.google.android.exoplayer.MediaCodecVideoTrackRenderer; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.audio.AudioCapabilities; +import com.google.android.exoplayer.chunk.ChunkSampleSource; +import com.google.android.exoplayer.chunk.ChunkSource; +import com.google.android.exoplayer.chunk.Format; +import com.google.android.exoplayer.chunk.FormatEvaluator; +//import com.google.android.exoplayer.chunk.MultiTrackChunkSource; +import com.google.android.exoplayer.dash.DashChunkSource; +import com.google.android.exoplayer.dash.DefaultDashTrackSelector; +import com.google.android.exoplayer.dash.mpd.AdaptationSet; +import com.google.android.exoplayer.dash.mpd.MediaPresentationDescription; +import com.google.android.exoplayer.dash.mpd.MediaPresentationDescriptionParser; +import com.google.android.exoplayer.dash.mpd.Period; +import com.google.android.exoplayer.dash.mpd.Representation; +import com.google.android.exoplayer.extractor.ExtractorSampleSource; +import com.google.android.exoplayer.hls.HlsChunkSource; +import com.google.android.exoplayer.hls.HlsPlaylist; +import com.google.android.exoplayer.hls.HlsPlaylistParser; +import com.google.android.exoplayer.hls.HlsSampleSource; +import com.google.android.exoplayer.text.Cue; +import com.google.android.exoplayer.text.TextRenderer; +import com.google.android.exoplayer.text.eia608.Eia608TrackRenderer; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DefaultAllocator; +import com.google.android.exoplayer.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer.upstream.DefaultUriDataSource; +import com.google.android.exoplayer.util.ManifestFetcher; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.Util; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A wrapper around {@link ExoPlayer} that provides a higher level interface. Designed for + * integration with {@link android.media.tv.TvInputService}. + */ +public class TvInputPlayer implements TextRenderer { + + public static final int SOURCE_TYPE_HTTP_PROGRESSIVE = 0; + public static final int SOURCE_TYPE_HLS = 1; + public static final int SOURCE_TYPE_MPEG_DASH = 2; + + private static final int RENDERER_COUNT = 3; + private static final int MIN_BUFFER_MS = 1000; + private static final int MIN_REBUFFER_MS = 5000; + + private static final int BUFFER_SEGMENTS = 300; + private static final int BUFFER_SEGMENT_SIZE = 64 * 1024; + private static final int VIDEO_BUFFER_SEGMENTS = 200; + private static final int AUDIO_BUFFER_SEGMENTS = 60; + private static final int LIVE_EDGE_LATENCY_MS = 30000; + + private static final int NO_TRACK_SELECTED = -1; + + private final Handler handler; + private final ExoPlayer player; + private TrackRenderer videoRenderer; + private TrackRenderer audioRenderer; + private TrackRenderer textRenderer; + private final CopyOnWriteArrayList callbacks; + private float volume; + private Surface surface; + private Long pendingSeekPosition; + private final TvTrackInfo[][] tvTracks = new TvTrackInfo[RENDERER_COUNT][]; + private final int[] selectedTvTracks = new int[RENDERER_COUNT]; + //private final MultiTrackChunkSource[] multiTrackChunkSources = + // new MultiTrackChunkSource[RENDERER_COUNT]; + + private final MediaCodecVideoTrackRenderer.EventListener videoRendererEventListener = + new MediaCodecVideoTrackRenderer.EventListener() { + @Override + public void onDroppedFrames(int count, long elapsed) { + // Do nothing. + } + + @Override + public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + + } + + @Override + public void onDrawnToSurface(Surface surface) { + for(Callback callback : callbacks) { + callback.onDrawnToSurface(surface); + } + } + + @Override + public void onDecoderInitialized(String decoderName, long elapsedRealtimeMs, long initializationDurationMs) { + + } + + @Override + public void onDecoderInitializationError( + MediaCodecTrackRenderer.DecoderInitializationException e) { + for(Callback callback : callbacks) { + callback.onPlayerError(new ExoPlaybackException(e)); + } + } + + @Override + public void onCryptoError(MediaCodec.CryptoException e) { + for(Callback callback : callbacks) { + callback.onPlayerError(new ExoPlaybackException(e)); + } + } + }; + + public TvInputPlayer() { + handler = new Handler(); + for (int i = 0; i < RENDERER_COUNT; ++i) { + tvTracks[i] = new TvTrackInfo[0]; + selectedTvTracks[i] = NO_TRACK_SELECTED; + } + callbacks = new CopyOnWriteArrayList<>(); + player = ExoPlayer.Factory.newInstance(RENDERER_COUNT, MIN_BUFFER_MS, MIN_REBUFFER_MS); + player.addListener(new ExoPlayer.Listener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + for (Callback callback : callbacks) { + callback.onPlayerStateChanged(playWhenReady, playbackState); + } + if (pendingSeekPosition != null && playbackState != ExoPlayer.STATE_IDLE + && playbackState != ExoPlayer.STATE_PREPARING) { + seekTo(pendingSeekPosition); + pendingSeekPosition = null; + } + } + + @Override + public void onPlayWhenReadyCommitted() { + for (Callback callback : callbacks) { + callback.onPlayWhenReadyCommitted(); + } + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + for (Callback callback : callbacks) { + callback.onPlayerError(e); + } + } + }); + } + + @Override + public void onCues(List cues) { + for (Callback callback : callbacks) { + callback.onCues(cues); + } + } + + public void prepare(final Context context, final Uri originalUri, int sourceType) { + + final String userAgent = getUserAgent(context); + final DefaultHttpDataSource dataSource = new DefaultHttpDataSource(userAgent, null); + final Uri uri = processUriParameters(originalUri, dataSource); + + if (sourceType == SOURCE_TYPE_HTTP_PROGRESSIVE) { + ExtractorSampleSource sampleSource = + new ExtractorSampleSource(uri, dataSource, new DefaultAllocator(BUFFER_SEGMENT_SIZE), + BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE); + audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource); + videoRenderer = new MediaCodecVideoTrackRenderer(sampleSource, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 0, handler, + videoRendererEventListener, 50); + textRenderer = new DummyTrackRenderer(); + prepareInternal(); + } else if (sourceType == SOURCE_TYPE_HLS) { + HlsPlaylistParser parser = new HlsPlaylistParser(); + ManifestFetcher playlistFetcher = + new ManifestFetcher<>(uri.toString(), dataSource, parser); + playlistFetcher.singleLoad(handler.getLooper(), + new ManifestFetcher.ManifestCallback() { + @Override + public void onSingleManifest(HlsPlaylist manifest) { + DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); + HlsChunkSource chunkSource = new HlsChunkSource(dataSource, + uri.toString(), manifest, bandwidthMeter, null, + HlsChunkSource.ADAPTIVE_MODE_SPLICE); + LoadControl lhc = new DefaultLoadControl(new DefaultAllocator(BUFFER_SEGMENT_SIZE)); + HlsSampleSource sampleSource = new HlsSampleSource(chunkSource, lhc, BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE); + audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource); + videoRenderer = new MediaCodecVideoTrackRenderer(sampleSource, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 0, handler, + videoRendererEventListener, 50); + textRenderer = new Eia608TrackRenderer(sampleSource, + TvInputPlayer.this, handler.getLooper()); + // TODO: Implement custom HLS source to get the internal track metadata. + tvTracks[TvTrackInfo.TYPE_SUBTITLE] = new TvTrackInfo[1]; + tvTracks[TvTrackInfo.TYPE_SUBTITLE][0] = + new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE, "1") + .build(); + prepareInternal(); + } + + @Override + public void onSingleManifestError(IOException e) { + for (Callback callback : callbacks) { + callback.onPlayerError(new ExoPlaybackException(e)); + } + } + }); + } else if (sourceType == SOURCE_TYPE_MPEG_DASH) { + MediaPresentationDescriptionParser parser = new MediaPresentationDescriptionParser(); + final ManifestFetcher manifestFetcher = + new ManifestFetcher<>(uri.toString(), dataSource, parser); + manifestFetcher.singleLoad(handler.getLooper(), + new ManifestFetcher.ManifestCallback() { + @Override + public void onSingleManifest(MediaPresentationDescription manifest) { + Period period = manifest.getPeriod(0); + LoadControl loadControl = new DefaultLoadControl(new DefaultAllocator( + BUFFER_SEGMENT_SIZE)); + + // Determine which video representations we should use for playback. + int maxDecodableFrameSize; + try { + maxDecodableFrameSize = MediaCodecUtil.maxH264DecodableFrameSize(); + } catch (MediaCodecUtil.DecoderQueryException e) { + for (Callback callback : callbacks) { + callback.onPlayerError(new ExoPlaybackException(e)); + } + return; + } + + int videoAdaptationSetIndex = period.getAdaptationSetIndex( + AdaptationSet.TYPE_VIDEO); + List videoRepresentations = + period.adaptationSets.get(videoAdaptationSetIndex).representations; + Collections.sort(videoRepresentations, new Comparator() { + @Override + public int compare(Representation r1, Representation r2) { + return Integer.compare(r2.format.bitrate, r1.format.bitrate); + } + }); + ArrayList videoRepresentationIndexList = new ArrayList<>(); + for (int i = 0; i < videoRepresentations.size(); i++) { + Format format = videoRepresentations.get(i).format; + if (format.width * format.height > maxDecodableFrameSize) { + // Filtering stream that device cannot play + } else if (!format.mimeType.equals(MimeTypes.VIDEO_MP4) + && !format.mimeType.equals(MimeTypes.VIDEO_WEBM)) { + // Filtering unsupported mime type + } else { + videoRepresentationIndexList.add(i); + } + } + + + // Build the video renderer. + if (videoRepresentationIndexList.isEmpty()) { + videoRenderer = new DummyTrackRenderer(); + } else { + DataSource videoDataSource = new DefaultUriDataSource(context, userAgent); + DefaultBandwidthMeter videoBandwidthMeter = new DefaultBandwidthMeter(); + ChunkSource videoChunkSource = new DashChunkSource(manifestFetcher, + DefaultDashTrackSelector.newVideoInstance(context, true, false), + videoDataSource, + new FormatEvaluator.AdaptiveEvaluator(videoBandwidthMeter), LIVE_EDGE_LATENCY_MS, + 0, true, null, null); + ChunkSampleSource videoSampleSource = new ChunkSampleSource( + videoChunkSource, loadControl, + VIDEO_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE); + videoRenderer = new MediaCodecVideoTrackRenderer(videoSampleSource, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 0, handler, + videoRendererEventListener, 50); + } + + // Build the audio chunk sources. + int audioAdaptationSetIndex = period.getAdaptationSetIndex( + AdaptationSet.TYPE_AUDIO); + AdaptationSet audioAdaptationSet = period.adaptationSets.get( + audioAdaptationSetIndex); + List audioChunkSourceList = new ArrayList<>(); + List audioTrackList = new ArrayList<>(); + if (audioAdaptationSet != null) { + DataSource audioDataSource = new DefaultUriDataSource(context, userAgent); + FormatEvaluator audioEvaluator = new FormatEvaluator.FixedEvaluator(); + List audioRepresentations = + audioAdaptationSet.representations; + for (int i = 0; i < audioRepresentations.size(); i++) { + Format format = audioRepresentations.get(i).format; + audioTrackList.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_AUDIO, + Integer.toString(i)) + .setAudioChannelCount(format.audioChannels) + .setAudioSampleRate(format.audioSamplingRate) + .setLanguage(format.language) + .build()); + audioChunkSourceList.add(new DashChunkSource(manifestFetcher, + DefaultDashTrackSelector.newAudioInstance(), + audioDataSource, + audioEvaluator, LIVE_EDGE_LATENCY_MS, 0, null, null)); + } + } + + // Build the audio renderer. + //final MultiTrackChunkSource audioChunkSource; + if (audioChunkSourceList.isEmpty()) { + audioRenderer = new DummyTrackRenderer(); + } else { + //audioChunkSource = new MultiTrackChunkSource(audioChunkSourceList); + //SampleSource audioSampleSource = new ChunkSampleSource(audioChunkSource, + // loadControl, AUDIO_BUFFER_SEGMENTS * BUFFER_SEGMENT_SIZE); + //audioRenderer = new MediaCodecAudioTrackRenderer(audioSampleSource); + TvTrackInfo[] tracks = new TvTrackInfo[audioTrackList.size()]; + audioTrackList.toArray(tracks); + tvTracks[TvTrackInfo.TYPE_AUDIO] = tracks; + selectedTvTracks[TvTrackInfo.TYPE_AUDIO] = 0; + //multiTrackChunkSources[TvTrackInfo.TYPE_AUDIO] = audioChunkSource; + } + + // Build the text renderer. + textRenderer = new DummyTrackRenderer(); + + prepareInternal(); + } + + @Override + public void onSingleManifestError(IOException e) { + for (Callback callback : callbacks) { + callback.onPlayerError(new ExoPlaybackException(e)); + } + } + }); + } else { + throw new IllegalArgumentException("Unknown source type: " + sourceType); + } + } + + public TvTrackInfo[] getTracks(int trackType) { + if (trackType < 0 || trackType >= tvTracks.length) { + throw new IllegalArgumentException("Illegal track type: " + trackType); + } + return tvTracks[trackType]; + } + + public String getSelectedTrack(int trackType) { + if (trackType < 0 || trackType >= tvTracks.length) { + throw new IllegalArgumentException("Illegal track type: " + trackType); + } + if (selectedTvTracks[trackType] == NO_TRACK_SELECTED) { + return null; + } + return tvTracks[trackType][selectedTvTracks[trackType]].getId(); + } + + public boolean selectTrack(int trackType, String trackId) { + if (trackType < 0 || trackType >= tvTracks.length) { + return false; + } + if (trackId == null) { + player.setRendererEnabled(trackType, false); + } else { + int trackIndex = Integer.parseInt(trackId); + /* + if (multiTrackChunkSources[trackType] == null) { + player.setRendererEnabled(trackType, true); + } else { + boolean playWhenReady = player.getPlayWhenReady(); + player.setPlayWhenReady(false); + player.setRendererEnabled(trackType, false); + player.sendMessage(multiTrackChunkSources[trackType], + MultiTrackChunkSource.MSG_SELECT_TRACK, trackIndex); + player.setRendererEnabled(trackType, true); + player.setPlayWhenReady(playWhenReady); + } + */ + } + return true; + } + + public void setPlayWhenReady(boolean playWhenReady) { + player.setPlayWhenReady(playWhenReady); + } + + public void setVolume(float volume) { + this.volume = volume; + if (player != null && audioRenderer != null) { + player.sendMessage(audioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, + volume); + } + } + + public void setSurface(Surface surface) { + this.surface = surface; + if (player != null && videoRenderer != null) { + player.sendMessage(videoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, + surface); + } + } + + public void seekTo(long position) { + if (isPlayerPrepared(player)) { // The player doesn't know the duration until prepared. + if (player.getDuration() != ExoPlayer.UNKNOWN_TIME) { + player.seekTo(position); + } + } else { + pendingSeekPosition = position; + } + } + + public void stop() { + player.stop(); + } + + public void release() { + player.release(); + } + + public void addCallback(Callback callback) { + callbacks.add(callback); + } + + public void removeCallback(Callback callback) { + callbacks.remove(callback); + } + + private void prepareInternal() { + player.prepare(audioRenderer, videoRenderer, textRenderer); + player.sendMessage(audioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, + volume); + player.sendMessage(videoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, + surface); + // Disable text track by default. + player.setRendererEnabled(TvTrackInfo.TYPE_SUBTITLE, false); + for (Callback callback : callbacks) { + callback.onPrepared(); + } + } + + private static String getUserAgent(Context context) { + String versionName; + try { + String packageName = context.getPackageName(); + PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + versionName = info.versionName; + } catch (PackageManager.NameNotFoundException e) { + versionName = "?"; + } + return "IptvLiveChannels/" + versionName + " (Linux;Android " + Build.VERSION.RELEASE + + ") " + "ExoPlayerLib/" + ExoPlayerLibraryInfo.VERSION; + } + + private static boolean isPlayerPrepared(ExoPlayer player) { + int state = player.getPlaybackState(); + return state != ExoPlayer.STATE_PREPARING && state != ExoPlayer.STATE_IDLE; + } + + private static Uri processUriParameters(Uri uri, DefaultHttpDataSource dataSource) { + String[] parameters = uri.getPath().split("\\|"); + for (int i = 1; i < parameters.length; i++) { + String[] pair = parameters[i].split("=", 2); + if (pair.length == 2) { + dataSource.setRequestProperty(pair[0], pair[1]); + } + } + + return uri.buildUpon().path(parameters[0]).build(); + } + + public interface Callback { + void onPrepared(); + void onPlayerStateChanged(boolean playWhenReady, int state); + void onPlayWhenReadyCommitted(); + void onPlayerError(ExoPlaybackException e); + void onDrawnToSurface(Surface surface); + void onCues(List cues); + } +} diff --git a/app/src/main/java/at/pansy/iptv/service/AccountService.java b/app/src/main/java/at/pansy/iptv/service/AccountService.java new file mode 100644 index 0000000..4adca6e --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/service/AccountService.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.service; + +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.NetworkErrorException; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; + + +/** + * Dummy account service for SyncAdapter. Note that this does nothing because this input uses a feed + * which does not require any authentication. + */ +public class AccountService extends Service { + + public static final String ACCOUNT_NAME = "IPTV Live Channels"; + + private Authenticator authenticator; + + public static Account getAccount(String accountType) { + return new Account(ACCOUNT_NAME, accountType); + } + + @Override + public void onCreate() { + authenticator = new Authenticator(this); + } + + @Override + public IBinder onBind(Intent intent) { + return authenticator.getIBinder(); + } + + /** + * Dummy Authenticator used in {@link SyncAdapter}. This does nothing for all the operations + * since channel/program feed does not require any authentication. + */ + public class Authenticator extends AbstractAccountAuthenticator { + public Authenticator(Context context) { + super(context); + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse accountAuthenticatorResponse, + String s) { + throw new UnsupportedOperationException(); + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse accountAuthenticatorResponse, + String s, String s2, String[] strings, Bundle bundle) throws NetworkErrorException { + return null; + } + + @Override + public Bundle confirmCredentials(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, Bundle bundle) throws NetworkErrorException { + return null; + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, String s, Bundle bundle) throws NetworkErrorException { + throw new UnsupportedOperationException(); + } + + @Override + public String getAuthTokenLabel(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, String s, Bundle bundle) throws NetworkErrorException { + throw new UnsupportedOperationException(); + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse accountAuthenticatorResponse, + Account account, String[] strings) throws NetworkErrorException { + throw new UnsupportedOperationException(); + } + } +} + diff --git a/app/src/main/java/at/pansy/iptv/service/SyncService.java b/app/src/main/java/at/pansy/iptv/service/SyncService.java new file mode 100644 index 0000000..19b1603 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/service/SyncService.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.service; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import at.pansy.iptv.sync.SyncAdapter; + +/** + * Service which provides the SyncAdapter implementation to the framework on request. + */ +public class SyncService extends Service { + private static final Object syncAdapterLock = new Object(); + private static SyncAdapter syncAdapter = null; + + @Override + public void onCreate() { + super.onCreate(); + synchronized (syncAdapterLock) { + if (syncAdapter == null) { + syncAdapter = new SyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/app/src/main/java/at/pansy/iptv/service/TvInputService.java b/app/src/main/java/at/pansy/iptv/service/TvInputService.java new file mode 100644 index 0000000..2bea519 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/service/TvInputService.java @@ -0,0 +1,404 @@ +package at.pansy.iptv.service; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.tv.TvContentRating; +import android.media.tv.TvInputManager; +import android.media.tv.TvTrackInfo; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Surface; +import android.view.View; +import android.view.accessibility.CaptioningManager; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.ExoPlayer; +import com.google.android.exoplayer.text.CaptionStyleCompat; +import com.google.android.exoplayer.text.Cue; +import com.google.android.exoplayer.text.SubtitleLayout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import at.pansy.iptv.R; +import at.pansy.iptv.domain.Channel; +import at.pansy.iptv.domain.PlaybackInfo; +import at.pansy.iptv.player.TvInputPlayer; +import at.pansy.iptv.util.SyncUtil; +import at.pansy.iptv.util.TvContractUtil; + +/** + * Created by notz. + */ +public class TvInputService extends android.media.tv.TvInputService { + + private static final String TAG = "TvInputService"; + + private HandlerThread handlerThread; + private Handler dbHandler; + + private List sessions; + private CaptioningManager captioningManager; + + private final BroadcastReceiver parentalControlsBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (sessions != null) { + for (TvInputSession session : sessions) { + session.checkContentBlockNeeded(); + } + } + } + }; + + @Override + public void onCreate() { + super.onCreate(); + handlerThread = new HandlerThread(getClass().getSimpleName()); + handlerThread.start(); + dbHandler = new Handler(handlerThread.getLooper()); + captioningManager = (CaptioningManager) getSystemService(Context.CAPTIONING_SERVICE); + + setTheme(android.R.style.Theme_Holo_Light_NoActionBar); + + sessions = new ArrayList<>(); + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(TvInputManager.ACTION_BLOCKED_RATINGS_CHANGED); + intentFilter.addAction(TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED); + registerReceiver(parentalControlsBroadcastReceiver, intentFilter); + } + + @Override + public void onDestroy() { + super.onDestroy(); + unregisterReceiver(parentalControlsBroadcastReceiver); + handlerThread.quit(); + handlerThread = null; + dbHandler = null; + } + + @Override + public final Session onCreateSession(String inputId) { + TvInputSession session = new TvInputSession(this, inputId); + session.setOverlayViewEnabled(true); + sessions.add(session); + return session; + } + + class TvInputSession extends android.media.tv.TvInputService.Session implements Handler.Callback { + + private static final int MSG_PLAY_PROGRAM = 1000; + + private final Context context; + private final TvInputManager tvInputManager; + protected TvInputPlayer player; + private Surface surface; + private float volume; + private boolean captionEnabled; + private PlaybackInfo currentPlaybackInfo; + private TvContentRating lastBlockedRating; + private TvContentRating currentContentRating; + private String celectedSubtitleTrackId; + private SubtitleLayout subtitleLayout; + private boolean epgSyncRequested; + private final Set unblockedRatingSet = new HashSet<>(); + private final Handler handler; + + private final TvInputPlayer.Callback playerCallback = new TvInputPlayer.Callback() { + + private boolean firstFrameDrawn; + + @Override + public void onPrepared() { + firstFrameDrawn = false; + List tracks = new ArrayList<>(); + Collections.addAll(tracks, player.getTracks(TvTrackInfo.TYPE_AUDIO)); + Collections.addAll(tracks, player.getTracks(TvTrackInfo.TYPE_VIDEO)); + Collections.addAll(tracks, player.getTracks(TvTrackInfo.TYPE_SUBTITLE)); + + notifyTracksChanged(tracks); + notifyTrackSelected(TvTrackInfo.TYPE_AUDIO, player.getSelectedTrack( + TvTrackInfo.TYPE_AUDIO)); + notifyTrackSelected(TvTrackInfo.TYPE_VIDEO, player.getSelectedTrack( + TvTrackInfo.TYPE_VIDEO)); + notifyTrackSelected(TvTrackInfo.TYPE_SUBTITLE, player.getSelectedTrack( + TvTrackInfo.TYPE_SUBTITLE)); + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playWhenReady && playbackState == ExoPlayer.STATE_BUFFERING) { + if (firstFrameDrawn) { + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING); + } + } else if (playWhenReady && playbackState == ExoPlayer.STATE_READY) { + notifyVideoAvailable(); + } + } + + @Override + public void onPlayWhenReadyCommitted() { + // Do nothing. + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + // Do nothing. + } + + @Override + public void onDrawnToSurface(Surface surface) { + firstFrameDrawn = true; + notifyVideoAvailable(); + } + + @Override + public void onCues(List cues) { + if (subtitleLayout != null) { + if (cues.isEmpty()) { + subtitleLayout.setVisibility(View.INVISIBLE); + } else { + subtitleLayout.setVisibility(View.VISIBLE); + subtitleLayout.setCues(cues); + } + } + } + }; + + private PlayCurrentProgramRunnable playCurrentProgramRunnable; + private String inputId; + + protected TvInputSession(Context context, String inputId) { + super(context); + this.context = context; + this.inputId = inputId; + + tvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); + lastBlockedRating = null; + captionEnabled = captioningManager.isEnabled(); + handler = new Handler(this); + } + + @Override + public boolean handleMessage(Message msg) { + if (msg.what == MSG_PLAY_PROGRAM) { + playProgram((PlaybackInfo) msg.obj); + return true; + } + return false; + } + + @Override + public void onRelease() { + if (dbHandler != null) { + dbHandler.removeCallbacks(playCurrentProgramRunnable); + } + releasePlayer(); + sessions.remove(this); + } + + @Override + public View onCreateOverlayView() { + LayoutInflater inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.overlay_view, null); + subtitleLayout = (SubtitleLayout) view.findViewById(R.id.subtitles); + + // Configure the subtitle view. + CaptionStyleCompat captionStyle; + captionStyle = CaptionStyleCompat.createFromCaptionStyle( + captioningManager.getUserStyle()); + subtitleLayout.setStyle(captionStyle); + subtitleLayout.setFractionalTextSize(captioningManager.getFontScale()); + return view; + } + + @Override + public boolean onSetSurface(Surface surface) { + if (player != null) { + player.setSurface(surface); + } + this.surface = surface; + return true; + } + + @Override + public void onSetStreamVolume(float volume) { + if (player != null) { + player.setVolume(volume); + } + this.volume = volume; + } + + private boolean playProgram(PlaybackInfo info) { + releasePlayer(); + + currentPlaybackInfo = info; + currentContentRating = (info.contentRatings == null || info.contentRatings.length == 0) + ? null : info.contentRatings[0]; + player = new TvInputPlayer(); + player.addCallback(playerCallback); + player.prepare(TvInputService.this, Uri.parse(info.videoUrl), info.videoType); + player.setSurface(surface); + player.setVolume(volume); + + long nowMs = System.currentTimeMillis(); + int seekPosMs = (int) (nowMs - info.startTimeMs); + if (seekPosMs > 0) { + player.seekTo(seekPosMs); + } + player.setPlayWhenReady(true); + + checkContentBlockNeeded(); + dbHandler.postDelayed(playCurrentProgramRunnable, info.endTimeMs - nowMs + 1000); + return true; + } + + @Override + public boolean onTune(Uri channelUri) { + if (subtitleLayout != null) { + subtitleLayout.setVisibility(View.INVISIBLE); + } + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + unblockedRatingSet.clear(); + + dbHandler.removeCallbacks(playCurrentProgramRunnable); + playCurrentProgramRunnable = new PlayCurrentProgramRunnable(channelUri); + dbHandler.post(playCurrentProgramRunnable); + return true; + } + + @Override + public void onSetCaptionEnabled(boolean enabled) { + captionEnabled = enabled; + if (player != null) { + if (enabled) { + if (celectedSubtitleTrackId != null) { + player.selectTrack(TvTrackInfo.TYPE_SUBTITLE, celectedSubtitleTrackId); + } + } else { + player.selectTrack(TvTrackInfo.TYPE_SUBTITLE, null); + } + } + } + + @Override + public boolean onSelectTrack(int type, String trackId) { + if (player != null) { + if (type == TvTrackInfo.TYPE_SUBTITLE) { + if (!captionEnabled && trackId != null) { + return false; + } + celectedSubtitleTrackId = trackId; + if (trackId == null) { + subtitleLayout.setVisibility(View.INVISIBLE); + } + } + if (player.selectTrack(type, trackId)) { + notifyTrackSelected(type, trackId); + return true; + } + } + return false; + } + + @Override + public void onUnblockContent(TvContentRating rating) { + if (rating != null) { + unblockContent(rating); + } + } + + private void releasePlayer() { + if (player != null) { + player.removeCallback(playerCallback); + player.setSurface(null); + player.stop(); + player.release(); + player = null; + } + } + + private void checkContentBlockNeeded() { + if (currentContentRating == null || !tvInputManager.isParentalControlsEnabled() + || !tvInputManager.isRatingBlocked(currentContentRating) + || unblockedRatingSet.contains(currentContentRating)) { + // Content rating is changed so we don't need to block anymore. + // Unblock content here explicitly to resume playback. + unblockContent(null); + return; + } + + lastBlockedRating = currentContentRating; + if (player != null) { + // Children restricted content might be blocked by TV app as well, + // but TIS should do its best not to show any single frame of blocked content. + releasePlayer(); + } + + notifyContentBlocked(currentContentRating); + } + + private void unblockContent(TvContentRating rating) { + // TIS should unblock content only if unblock request is legitimate. + if (rating == null || lastBlockedRating == null + || rating.equals(lastBlockedRating)) { + lastBlockedRating = null; + if (rating != null) { + unblockedRatingSet.add(rating); + } + if (player == null && currentPlaybackInfo != null) { + playProgram(currentPlaybackInfo); + } + notifyContentAllowed(); + } + } + + private class PlayCurrentProgramRunnable implements Runnable { + + private static final int RETRY_DELAY_MS = 2000; + private final Uri mChannelUri; + + public PlayCurrentProgramRunnable(Uri channelUri) { + mChannelUri = channelUri; + } + + @Override + public void run() { + long nowMs = System.currentTimeMillis(); + List programs = TvContractUtil.getProgramPlaybackInfo( + context.getContentResolver(), mChannelUri, nowMs, nowMs + 1, 1); + if (programs.isEmpty()) { + Log.w(TAG, "Failed to get program info for " + mChannelUri + ". Retry in " + + RETRY_DELAY_MS + "ms."); + if (!epgSyncRequested) { + SyncUtil.requestSync(inputId, true); + epgSyncRequested = true; + } + + String url = null; + + Channel channel = TvContractUtil.getChannel(context.getContentResolver(), mChannelUri); + if (channel != null) { + url = channel.getInternalProviderData(); + } + PlaybackInfo playbackInfo = new PlaybackInfo(nowMs, nowMs + 3600 * 1000l, + url, 1, new TvContentRating[] {}); + programs.add(playbackInfo); + } + + handler.removeMessages(MSG_PLAY_PROGRAM); + handler.obtainMessage(MSG_PLAY_PROGRAM, programs.get(0)).sendToTarget(); + } + } + } +} + diff --git a/app/src/main/java/at/pansy/iptv/sync/SyncAdapter.java b/app/src/main/java/at/pansy/iptv/sync/SyncAdapter.java new file mode 100644 index 0000000..67588da --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/sync/SyncAdapter.java @@ -0,0 +1,298 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.sync; + +import android.accounts.Account; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentUris; +import android.content.Context; +import android.content.OperationApplicationException; +import android.content.SyncResult; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; +import android.util.Log; +import android.util.LongSparseArray; + +import java.util.ArrayList; +import java.util.List; + +import at.pansy.iptv.R; +import at.pansy.iptv.domain.Program; +import at.pansy.iptv.util.IptvUtil; +import at.pansy.iptv.util.TvContractUtil; +import at.pansy.iptv.xmltv.XmlTvParser; + +/** + * A SyncAdapter implementation which updates program info periodically. + */ +public class SyncAdapter extends AbstractThreadedSyncAdapter { + public static final String TAG = "SyncAdapter"; + + public static final String BUNDLE_KEY_INPUT_ID = "bundle_key_input_id"; + public static final String BUNDLE_KEY_CURRENT_PROGRAM_ONLY = "bundle_key_current_program_only"; + public static final long FULL_SYNC_FREQUENCY_SEC = 60 * 60 * 24; // daily + + private static final int FULL_SYNC_WINDOW_SEC = 60 * 60 * 24 * 14; // 2 weeks + private static final int SHORT_SYNC_WINDOW_SEC = 60 * 60; // 1 hour + private static final int BATCH_OPERATION_COUNT = 100; + + private final Context context; + + public SyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + this.context = context; + } + + public SyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + this.context = context; + } + + /** + * Called periodically by the system in every {@code FULL_SYNC_FREQUENCY_SEC}. + */ + @Override + public void onPerformSync(Account account, Bundle extras, String authority, + ContentProviderClient provider, SyncResult syncResult) { + + Log.d(TAG, "onPerformSync(" + account + ", " + authority + ", " + extras + ")"); + String inputId = extras.getString(SyncAdapter.BUNDLE_KEY_INPUT_ID); + if (inputId == null) { + return; + } + + XmlTvParser.TvListing listings = IptvUtil.getTvListings(context, + context.getString(R.string.iptv_ink_epg_url), IptvUtil.FORMAT_XMLTV); + + XmlTvParser.TvListing channelListings = IptvUtil.getTvListings(context, + context.getString(R.string.iptv_ink_channel_url), IptvUtil.FORMAT_M3U); + listings.setChannels(channelListings.channels); + + + LongSparseArray channelMap = TvContractUtil.buildChannelMap( + context.getContentResolver(), inputId, listings.channels); + boolean currentProgramOnly = extras.getBoolean( + SyncAdapter.BUNDLE_KEY_CURRENT_PROGRAM_ONLY, false); + long startMs = System.currentTimeMillis(); + long endMs = startMs + FULL_SYNC_WINDOW_SEC * 1000; + if (currentProgramOnly) { + // This is requested from the setup activity, in this case, users don't need to wait for + // the full sync. Sync the current programs first and do the full sync later in the + // background. + endMs = startMs + SHORT_SYNC_WINDOW_SEC * 1000; + } + for (int i = 0; i < channelMap.size(); ++i) { + Uri channelUri = TvContract.buildChannelUri(channelMap.keyAt(i)); + List programs = getPrograms(channelUri, channelMap.valueAt(i), + listings.programs, startMs, endMs); + updatePrograms(channelUri, programs); + } + } + + /** + * Returns a list of programs for the given time range. + * + * @param channelUri The channel where the program info will be added. + * @param channel The {@link XmlTvParser.XmlTvChannel} for the programs to return. + * @param programs The feed fetched from cloud. + * @param startTimeMs The start time of the range requested. + * @param endTimeMs The end time of the range requested. + */ + private List getPrograms(Uri channelUri, XmlTvParser.XmlTvChannel channel, + List programs, long startTimeMs, long endTimeMs) { + if (startTimeMs > endTimeMs) { + throw new IllegalArgumentException(); + } + List channelPrograms = new ArrayList<>(); + for (XmlTvParser.XmlTvProgram program : programs) { + if (program.channelId.equals(channel.id)) { + channelPrograms.add(program); + } + } + + List programForGivenTime = new ArrayList<>(); + if (!channel.repeatPrograms) { + for (XmlTvParser.XmlTvProgram program : channelPrograms) { + if (program.startTimeUtcMillis <= endTimeMs + && program.endTimeUtcMillis >= startTimeMs) { + programForGivenTime.add(new Program.Builder() + .setChannelId(ContentUris.parseId(channelUri)) + .setTitle(program.title) + .setDescription(program.description) + .setContentRatings(XmlTvParser.xmlTvRatingToTvContentRating( + program.rating)) + .setCanonicalGenres(program.category) + .setPosterArtUri(program.icon != null ? program.icon.src : null) + .setInternalProviderData(TvContractUtil. + convertVideoInfoToInternalProviderData( + program.videoType, + program.videoSrc != null ? program.videoSrc : channel.url)) + .setStartTimeUtcMillis(program.startTimeUtcMillis) + .setEndTimeUtcMillis(program.endTimeUtcMillis) + .build() + ); + } + } + return programForGivenTime; + } + + // If repeat-programs is on, schedule the programs sequentially in a loop. To make every + // device play the same program in a given channel and time, we assumes the loop started + // from the epoch time. + long totalDurationMs = 0; + for (XmlTvParser.XmlTvProgram program : channelPrograms) { + totalDurationMs += program.getDurationMillis(); + } + + long programStartTimeMs = startTimeMs - startTimeMs % totalDurationMs; + int i = 0; + final int programCount = channelPrograms.size(); + while (programStartTimeMs < endTimeMs) { + XmlTvParser.XmlTvProgram programInfo = channelPrograms.get(i++ % programCount); + long programEndTimeMs = programStartTimeMs + programInfo.getDurationMillis(); + if (programEndTimeMs < startTimeMs) { + programStartTimeMs = programEndTimeMs; + continue; + } + programForGivenTime.add(new Program.Builder() + .setChannelId(ContentUris.parseId(channelUri)) + .setTitle(programInfo.title) + .setDescription(programInfo.description) + .setContentRatings(XmlTvParser.xmlTvRatingToTvContentRating( + programInfo.rating)) + .setCanonicalGenres(programInfo.category) + .setPosterArtUri(programInfo.icon.src) + // NOTE: {@code COLUMN_INTERNAL_PROVIDER_DATA} is a private field where + // TvInputService can store anything it wants. Here, we store video type and + // video URL so that TvInputService can play the video later with this field. + .setInternalProviderData(TvContractUtil.convertVideoInfoToInternalProviderData( + programInfo.videoType, + programInfo.videoSrc != null ? programInfo.videoSrc : channel.url)) + .setStartTimeUtcMillis(programStartTimeMs) + .setEndTimeUtcMillis(programEndTimeMs) + .build() + ); + programStartTimeMs = programEndTimeMs; + } + return programForGivenTime; + } + + /** + * Updates the system database, TvProvider, with the given programs. + * + *

If there is any overlap between the given and existing programs, the existing ones + * will be updated with the given ones if they have the same title or replaced. + * + * @param channelUri The channel where the program info will be added. + * @param newPrograms A list of {@link Program} instances which includes program + * information. + */ + private void updatePrograms(Uri channelUri, List newPrograms) { + final int fetchedProgramsCount = newPrograms.size(); + if (fetchedProgramsCount == 0) { + return; + } + List oldPrograms = TvContractUtil.getPrograms(context.getContentResolver(), + channelUri); + Program firstNewProgram = newPrograms.get(0); + int oldProgramsIndex = 0; + int newProgramsIndex = 0; + // Skip the past programs. They will be automatically removed by the system. + for (Program program : oldPrograms) { + oldProgramsIndex++; + if(program.getEndTimeUtcMillis() > firstNewProgram.getStartTimeUtcMillis()) { + break; + } + } + // Compare the new programs with old programs one by one and update/delete the old one or + // insert new program if there is no matching program in the database. + ArrayList ops = new ArrayList<>(); + while (newProgramsIndex < fetchedProgramsCount) { + Program oldProgram = oldProgramsIndex < oldPrograms.size() + ? oldPrograms.get(oldProgramsIndex) : null; + Program newProgram = newPrograms.get(newProgramsIndex); + boolean addNewProgram = false; + if (oldProgram != null) { + if (oldProgram.equals(newProgram)) { + // Exact match. No need to update. Move on to the next programs. + oldProgramsIndex++; + newProgramsIndex++; + } else if (needsUpdate(oldProgram, newProgram)) { + // Partial match. Update the old program with the new one. + // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There could + // be application specific settings which belong to the old program. + ops.add(ContentProviderOperation.newUpdate( + TvContract.buildProgramUri(oldProgram.getProgramId())) + .withValues(newProgram.toContentValues()) + .build()); + oldProgramsIndex++; + newProgramsIndex++; + } else if (oldProgram.getEndTimeUtcMillis() < newProgram.getEndTimeUtcMillis()) { + // No match. Remove the old program first to see if the next program in + // {@code oldPrograms} partially matches the new program. + ops.add(ContentProviderOperation.newDelete( + TvContract.buildProgramUri(oldProgram.getProgramId())) + .build()); + oldProgramsIndex++; + } else { + // No match. The new program does not match any of the old programs. Insert it + // as a new program. + addNewProgram = true; + newProgramsIndex++; + } + } else { + // No old programs. Just insert new programs. + addNewProgram = true; + newProgramsIndex++; + } + if (addNewProgram) { + ops.add(ContentProviderOperation + .newInsert(TvContract.Programs.CONTENT_URI) + .withValues(newProgram.toContentValues()) + .build()); + } + // Throttle the batch operation not to cause TransactionTooLargeException. + if (ops.size() > BATCH_OPERATION_COUNT + || newProgramsIndex >= fetchedProgramsCount) { + try { + context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Failed to insert programs.", e); + return; + } + ops.clear(); + } + } + } + + /** + * Returns {@code true} if the {@code oldProgram} program needs to be updated with the + * {@code newProgram} program. + */ + private boolean needsUpdate(Program oldProgram, Program newProgram) { + // NOTE: Here, we update the old program if it has the same title and overlaps with the new + // program. The test logic is just an example and you can modify this. E.g. check whether + // the both programs have the same program ID if your EPG supports any ID for the programs. + return oldProgram.getTitle().equals(newProgram.getTitle()) + && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() + && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); + } +} diff --git a/app/src/main/java/at/pansy/iptv/util/IptvUtil.java b/app/src/main/java/at/pansy/iptv/util/IptvUtil.java new file mode 100644 index 0000000..ddacc12 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/util/IptvUtil.java @@ -0,0 +1,140 @@ +package at.pansy.iptv.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.net.URLConnection; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import at.pansy.iptv.xmltv.XmlTvParser; + +/** + * Static helper methods for fetching the channel feed. + */ +public class IptvUtil { + + public static final int FORMAT_XMLTV = 0; + public static final int FORMAT_M3U = 1; + + private static final String TAG = "IptvUtil"; + private static HashMap sampleTvListings = new HashMap<>(); + + private static final int URLCONNECTION_CONNECTION_TIMEOUT_MS = 3000; // 3 sec + private static final int URLCONNECTION_READ_TIMEOUT_MS = 10000; // 10 sec + + private IptvUtil() { + } + + public static XmlTvParser.TvListing getTvListings(Context context, String url, int format) { + + if (sampleTvListings.containsKey(url)) { + return sampleTvListings.get(url); + } + + Uri catalogUri = + Uri.parse(url).normalizeScheme(); + + XmlTvParser.TvListing sampleTvListing = null; + try { + InputStream inputStream = getInputStream(context, catalogUri); + if (url.endsWith(".gz")) { + inputStream = new GZIPInputStream(inputStream); + } + if (format == FORMAT_M3U) { + sampleTvListing = parse(inputStream); + } else { + sampleTvListing = XmlTvParser.parse(inputStream); + } + } catch (IOException e) { + Log.e(TAG, "Error in fetching " + catalogUri, e); + } + if (sampleTvListing != null) { + sampleTvListings.put(url, sampleTvListing); + } + return sampleTvListing; + } + + public static InputStream getInputStream(Context context, Uri uri) throws IOException { + InputStream inputStream; + if (ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme()) + || ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme()) + || ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { + inputStream = context.getContentResolver().openInputStream(uri); + } else { + URLConnection urlConnection = new URL(uri.toString()).openConnection(); + urlConnection.setConnectTimeout(URLCONNECTION_CONNECTION_TIMEOUT_MS); + urlConnection.setReadTimeout(URLCONNECTION_READ_TIMEOUT_MS); + inputStream = urlConnection.getInputStream(); + } + return new BufferedInputStream(inputStream); + } + + private static XmlTvParser.TvListing parse(InputStream inputStream) throws IOException { + BufferedReader in = new BufferedReader(new InputStreamReader(inputStream)); + String line; + List channels = new ArrayList<>(); + List programs = new ArrayList<>(); + Map channelMap = new HashMap<>(); + + while ((line = in.readLine()) != null) { + if (line.startsWith("#EXTINF:")) { + // #EXTINF:0051 tvg-id="blizz.de" group-title="DE Spartensender" tvg-logo="897815.png", [COLOR orangered]blizz TV HD[/COLOR] + + String id = null; + String displayName = null; + String displayNumber = null; + int originalNetworkId = 0; + XmlTvParser.XmlTvIcon icon = null; + + String[] parts = line.split(", ", 2); + if (parts.length == 2) { + for (String part : parts[0].split(" ")) { + if (part.startsWith("#EXTINF:")) { + displayNumber = part.substring(8).replaceAll("^0+", ""); + originalNetworkId = Integer.parseInt(displayNumber); + } else if (part.startsWith("tvg-id=")) { + int end = part.indexOf("\"", 8); + if (end > 8) { + id = part.substring(8, end); + } + } else if (part.startsWith("tvg-logo=")) { + int end = part.indexOf("\"", 10); + if (end > 10) { + icon = new XmlTvParser.XmlTvIcon("http://logo.iptv.ink/" + + part.substring(10, end)); + } + } + } + displayName = parts[1].replaceAll("\\[\\/?COLOR[^\\]]*\\]", ""); + } + + if (originalNetworkId != 0 && displayName != null) { + XmlTvParser.XmlTvChannel channel = + new XmlTvParser.XmlTvChannel(id, displayName, displayNumber, icon, + originalNetworkId, 0, 0, false); + if (channelMap.containsKey(originalNetworkId)) { + channels.set(channelMap.get(originalNetworkId), channel); + } else { + channelMap.put(originalNetworkId, channels.size()); + channels.add(channel); + } + } + } else if (line.startsWith("http") && channels.size() > 0) { + channels.get(channels.size()-1).url = line; + } + } + return new XmlTvParser.TvListing(channels, programs); + } +} diff --git a/app/src/main/java/at/pansy/iptv/util/RendererUtil.java b/app/src/main/java/at/pansy/iptv/util/RendererUtil.java new file mode 100644 index 0000000..a647126 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/util/RendererUtil.java @@ -0,0 +1,41 @@ +package at.pansy.iptv.util; + +import android.content.Context; +; +import com.google.android.exoplayer.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer.upstream.DefaultUriDataSource; + +import java.util.HashMap; +import java.util.Map; + +/** + * Created by notz. + */ +public class RendererUtil { + + public static String processUrlParameters(String url, HashMap httpHeaders) { + String[] parameters = url.split("\\|"); + for (int i = 1; i < parameters.length; i++) { + String[] pair = parameters[i].split("=", 2); + if (pair.length == 2) { + httpHeaders.put(pair[0], pair[1]); + } + } + + return parameters[0]; + } + + public static DefaultUriDataSource createDefaultUriDataSource(Context context, String userAgent, + HashMap httpHeaders) { + + DefaultHttpDataSource httpDataSource = new DefaultHttpDataSource(userAgent, null, null, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, false); + + for (Map.Entry header : httpHeaders.entrySet()) { + httpDataSource.setRequestProperty(header.getKey(), header.getValue()); + } + + return new DefaultUriDataSource(context, null, httpDataSource); + } +} diff --git a/app/src/main/java/at/pansy/iptv/util/SyncUtil.java b/app/src/main/java/at/pansy/iptv/util/SyncUtil.java new file mode 100644 index 0000000..54eaa58 --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/util/SyncUtil.java @@ -0,0 +1,64 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.util; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; +import android.content.Context; +import android.media.tv.TvContract; +import android.os.Bundle; +import android.util.Log; + +import at.pansy.iptv.service.AccountService; +import at.pansy.iptv.sync.SyncAdapter; + +/** + * Static helper methods for working with the SyncAdapter framework. + */ +public class SyncUtil { + + public static final String ACCOUNT_TYPE = "at.pansy.iptv.account"; + + private static final String TAG = "SyncUtil"; + private static final String CONTENT_AUTHORITY = TvContract.AUTHORITY; + + public static void setUpPeriodicSync(Context context, String inputId) { + Account account = AccountService.getAccount(ACCOUNT_TYPE); + AccountManager accountManager = + (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE); + if (!accountManager.addAccountExplicitly(account, null, null)) { + Log.e(TAG, "Account already exists."); + } + ContentResolver.setIsSyncable(account, CONTENT_AUTHORITY, 1); + ContentResolver.setSyncAutomatically(account, CONTENT_AUTHORITY, true); + Bundle bundle = new Bundle(); + bundle.putString(SyncAdapter.BUNDLE_KEY_INPUT_ID, inputId); + ContentResolver.addPeriodicSync(account, CONTENT_AUTHORITY, bundle, + SyncAdapter.FULL_SYNC_FREQUENCY_SEC); + } + + public static void requestSync(String inputId, boolean currentProgramOnly) { + Bundle bundle = new Bundle(); + bundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); + bundle.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); + bundle.putString(SyncAdapter.BUNDLE_KEY_INPUT_ID, inputId); + bundle.putBoolean(SyncAdapter.BUNDLE_KEY_CURRENT_PROGRAM_ONLY, currentProgramOnly); + ContentResolver.requestSync(AccountService.getAccount(ACCOUNT_TYPE), CONTENT_AUTHORITY, + bundle); + } +} diff --git a/app/src/main/java/at/pansy/iptv/util/TvContractUtil.java b/app/src/main/java/at/pansy/iptv/util/TvContractUtil.java new file mode 100644 index 0000000..18f0fec --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/util/TvContractUtil.java @@ -0,0 +1,349 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.util; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.media.tv.TvContentRating; +import android.media.tv.TvContract; +import android.media.tv.TvContract.Channels; +import android.media.tv.TvContract.Programs; +import android.net.Uri; +import android.os.AsyncTask; +import android.text.TextUtils; +import android.util.Log; +import android.util.LongSparseArray; +import android.util.Pair; +import android.util.SparseArray; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import at.pansy.iptv.domain.Channel; +import at.pansy.iptv.domain.PlaybackInfo; +import at.pansy.iptv.domain.Program; +import at.pansy.iptv.xmltv.XmlTvParser; + +/** + * Static helper methods for working with {@link TvContract}. + */ +public class TvContractUtil { + private static final String TAG = "TvContractUtils"; + private static final boolean DEBUG = true; + + private static final SparseArray VIDEO_HEIGHT_TO_FORMAT_MAP = new SparseArray<>(); + + static { + VIDEO_HEIGHT_TO_FORMAT_MAP.put(480, Channels.VIDEO_FORMAT_480P); + VIDEO_HEIGHT_TO_FORMAT_MAP.put(576, Channels.VIDEO_FORMAT_576P); + VIDEO_HEIGHT_TO_FORMAT_MAP.put(720, Channels.VIDEO_FORMAT_720P); + VIDEO_HEIGHT_TO_FORMAT_MAP.put(1080, Channels.VIDEO_FORMAT_1080P); + VIDEO_HEIGHT_TO_FORMAT_MAP.put(2160, Channels.VIDEO_FORMAT_2160P); + VIDEO_HEIGHT_TO_FORMAT_MAP.put(4320, Channels.VIDEO_FORMAT_4320P); + } + + private TvContractUtil() {} + + public static void updateChannels( + Context context, String inputId, List channels) { + // Create a map from original network ID to channel row ID for existing channels. + SparseArray mExistingChannelsMap = new SparseArray<>(); + Uri channelsUri = TvContract.buildChannelsUriForInput(inputId); + String[] projection = {Channels._ID, Channels.COLUMN_ORIGINAL_NETWORK_ID}; + Cursor cursor = null; + ContentResolver resolver = context.getContentResolver(); + try { + cursor = resolver.query(channelsUri, projection, null, null, null); + while (cursor != null && cursor.moveToNext()) { + long rowId = cursor.getLong(0); + int originalNetworkId = cursor.getInt(1); + mExistingChannelsMap.put(originalNetworkId, rowId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + // If a channel exists, update it. If not, insert a new one. + ContentValues values = new ContentValues(); + values.put(Channels.COLUMN_INPUT_ID, inputId); + Map logos = new HashMap<>(); + for (XmlTvParser.XmlTvChannel channel : channels) { + values.put(Channels.COLUMN_DISPLAY_NUMBER, channel.displayNumber); + values.put(Channels.COLUMN_DISPLAY_NAME, channel.displayName); + values.put(Channels.COLUMN_ORIGINAL_NETWORK_ID, channel.originalNetworkId); + values.put(Channels.COLUMN_TRANSPORT_STREAM_ID, channel.transportStreamId); + values.put(Channels.COLUMN_SERVICE_ID, channel.serviceId); + values.put(Channels.COLUMN_INTERNAL_PROVIDER_DATA, channel.url); + Long rowId = mExistingChannelsMap.get(channel.originalNetworkId); + Uri uri; + if (rowId == null) { + uri = resolver.insert(Channels.CONTENT_URI, values); + } else { + uri = TvContract.buildChannelUri(rowId); + resolver.update(uri, values, null, null); + mExistingChannelsMap.remove(channel.originalNetworkId); + } + if (!TextUtils.isEmpty(channel.icon.src)) { + logos.put(TvContract.buildChannelLogoUri(uri), channel.icon.src); + } + } + if (!logos.isEmpty()) { + new InsertLogosTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, logos); + } + + // Deletes channels which don't exist in the new feed. + int size = mExistingChannelsMap.size(); + for (int i = 0; i < size; i++) { + Long rowId = mExistingChannelsMap.valueAt(i); + resolver.delete(TvContract.buildChannelUri(rowId), null, null); + } + } + + private static String getVideoFormat(int videoHeight) { + return VIDEO_HEIGHT_TO_FORMAT_MAP.get(videoHeight); + } + + public static LongSparseArray buildChannelMap( + ContentResolver resolver, String inputId, List channels) { + Uri uri = TvContract.buildChannelsUriForInput(inputId); + String[] projection = { + Channels._ID, + Channels.COLUMN_DISPLAY_NUMBER + }; + + LongSparseArray channelMap = new LongSparseArray<>(); + Cursor cursor = null; + try { + cursor = resolver.query(uri, projection, null, null, null); + if (cursor == null || cursor.getCount() == 0) { + return null; + } + + while (cursor.moveToNext()) { + long channelId = cursor.getLong(0); + String channelNumber = cursor.getString(1); + channelMap.put(channelId, getChannelByNumber(channelNumber, channels)); + } + } catch (Exception e) { + Log.d(TAG, "Content provider query: " + e.getStackTrace()); + return null; + } finally { + if (cursor != null) { + cursor.close(); + } + } + return channelMap; + } + + public static Channel getChannel(ContentResolver resolver, Uri channelUri) { + Cursor cursor = null; + try { + // TvProvider returns programs chronological order by default. + cursor = resolver.query(channelUri, null, null, null, null); + if (cursor == null || cursor.getCount() == 0) { + return null; + } + if (cursor.moveToNext()) { + return Channel.fromCursor(cursor); + } + } catch (Exception e) { + Log.w(TAG, "Unable to get channel for " + channelUri, e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + public static List getPrograms(ContentResolver resolver, Uri channelUri) { + Uri uri = TvContract.buildProgramsUriForChannel(channelUri); + Cursor cursor = null; + List programs = new ArrayList<>(); + try { + // TvProvider returns programs chronological order by default. + cursor = resolver.query(uri, null, null, null, null); + if (cursor == null || cursor.getCount() == 0) { + return programs; + } + while (cursor.moveToNext()) { + programs.add(Program.fromCursor(cursor)); + } + } catch (Exception e) { + Log.w(TAG, "Unable to get programs for " + channelUri, e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return programs; + } + + public static List getProgramPlaybackInfo( + ContentResolver resolver, Uri channelUri, long startTimeMs, long endTimeMs, + int maxProgramInReturn) { + Uri uri = TvContract.buildProgramsUriForChannel(channelUri, startTimeMs, + endTimeMs); + String[] projection = { Programs.COLUMN_START_TIME_UTC_MILLIS, + Programs.COLUMN_END_TIME_UTC_MILLIS, + Programs.COLUMN_CONTENT_RATING, + Programs.COLUMN_INTERNAL_PROVIDER_DATA, + Programs.COLUMN_CANONICAL_GENRE }; + Cursor cursor = null; + List list = new ArrayList<>(); + try { + cursor = resolver.query(uri, projection, null, null, null); + while (cursor != null && cursor.moveToNext()) { + long startMs = cursor.getLong(0); + long endMs = cursor.getLong(1); + TvContentRating[] ratings = stringToContentRatings(cursor.getString(2)); + Pair values = parseInternalProviderData(cursor.getString(3)); + int videoType = values.first; + String videoUrl = values.second; + list.add(new PlaybackInfo(startMs, endMs, videoUrl, videoType, ratings)); + if (list.size() > maxProgramInReturn) { + break; + } + } + } catch (Exception e) { + Log.e(TAG, "Failed to get program playback info from TvProvider.", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return list; + } + + public static String convertVideoInfoToInternalProviderData(int videotype, String videoUrl) { + return videotype + "," + videoUrl; + } + + public static Pair parseInternalProviderData(String internalData) { + String[] values = internalData.split(",", 2); + if (values.length != 2) { + throw new IllegalArgumentException(internalData); + } + return new Pair<>(Integer.parseInt(values[0]), values[1]); + } + + public static void insertUrl(Context context, Uri contentUri, URL sourceUrl) { + if (DEBUG) { + Log.d(TAG, "Inserting " + sourceUrl + " to " + contentUri); + } + InputStream is = null; + OutputStream os = null; + try { + is = sourceUrl.openStream(); + os = context.getContentResolver().openOutputStream(contentUri); + copy(is, os); + } catch (IOException ioe) { + Log.e(TAG, "Failed to write " + sourceUrl + " to " + contentUri, ioe); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Ignore exception. + } + } + if (os != null) { + try { + os.close(); + } catch (IOException e) { + // Ignore exception. + } + } + } + } + + public static void copy(InputStream is, OutputStream os) throws IOException { + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + } + + public static TvContentRating[] stringToContentRatings(String commaSeparatedRatings) { + if (TextUtils.isEmpty(commaSeparatedRatings)) { + return null; + } + String[] ratings = commaSeparatedRatings.split("\\s*,\\s*"); + TvContentRating[] contentRatings = new TvContentRating[ratings.length]; + for (int i = 0; i < contentRatings.length; ++i) { + contentRatings[i] = TvContentRating.unflattenFromString(ratings[i]); + } + return contentRatings; + } + + public static String contentRatingsToString(TvContentRating[] contentRatings) { + if (contentRatings == null || contentRatings.length == 0) { + return null; + } + final String DELIMITER = ","; + StringBuilder ratings = new StringBuilder(contentRatings[0].flattenToString()); + for (int i = 1; i < contentRatings.length; ++i) { + ratings.append(DELIMITER); + ratings.append(contentRatings[i].flattenToString()); + } + return ratings.toString(); + } + + private static XmlTvParser.XmlTvChannel getChannelByNumber(String channelNumber, + List channels) { + for (XmlTvParser.XmlTvChannel channel : channels) { + if (channelNumber.equals(channel.displayNumber)) { + return channel; + } + } + throw new IllegalArgumentException("Unknown channel: " + channelNumber); + } + + public static class InsertLogosTask extends AsyncTask, Void, Void> { + private final Context context; + + InsertLogosTask(Context context) { + this.context = context; + } + + @Override + public Void doInBackground(Map... logosList) { + for (Map logos : logosList) { + for (Uri uri : logos.keySet()) { + try { + insertUrl(context, uri, new URL(logos.get(uri))); + } catch (MalformedURLException e) { + Log.e(TAG, "Can't load " + logos.get(uri), e); + } + } + } + return null; + } + } +} diff --git a/app/src/main/java/at/pansy/iptv/xmltv/XmlTvParser.java b/app/src/main/java/at/pansy/iptv/xmltv/XmlTvParser.java new file mode 100644 index 0000000..7ee9adc --- /dev/null +++ b/app/src/main/java/at/pansy/iptv/xmltv/XmlTvParser.java @@ -0,0 +1,392 @@ +/* + * Copyright 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.pansy.iptv.xmltv; + +import android.media.tv.TvContentRating; +import android.text.TextUtils; +import android.util.Xml; + +import com.google.android.exoplayer.ParserException; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; + +import at.pansy.iptv.domain.PlaybackInfo; + +/** + * XMLTV document parser which conforms to http://wiki.xmltv.org/index.php/Main_Page + * + *

Please note that xmltv.dtd are extended to be align with Android TV Input Framework and + * contain static video contents: + * + * + * + * + * video-type CDATA #IMPLIED > + * + * display-number : The channel number that is displayed to the user. + * repeat-programs : If "true", the programs in the xml document are scheduled sequentially in a + * loop regardless of their start and end time. This is introduced to simulate a live + * channel in this sample. + * video-src : The video URL for the given program. This can be omitted if the xml will be used + * only for the program guide update. + * video-type : The video type. Should be one of "HTTP_PROGRESSIVE", "HLS", and "MPEG-DASH". This + * can be omitted if the xml will be used only for the program guide update. + */ +public class XmlTvParser { + private static final String TAG_TV = "tv"; + private static final String TAG_CHANNEL = "channel"; + private static final String TAG_DISPLAY_NAME = "display-name"; + private static final String TAG_ICON = "icon"; + private static final String TAG_PROGRAM = "programme"; + private static final String TAG_TITLE = "title"; + private static final String TAG_DESC = "desc"; + private static final String TAG_CATEGORY = "category"; + private static final String TAG_RATING = "rating"; + private static final String TAG_VALUE = "value"; + private static final String TAG_DISPLAY_NUMBER = "display-number"; + + private static final String ATTR_ID = "id"; + private static final String ATTR_START = "start"; + private static final String ATTR_STOP = "stop"; + private static final String ATTR_CHANNEL = "channel"; + private static final String ATTR_SYSTEM = "system"; + private static final String ATTR_SRC = "src"; + private static final String ATTR_REPEAT_PROGRAMS = "repeat-programs"; + private static final String ATTR_VIDEO_SRC = "video-src"; + private static final String ATTR_VIDEO_TYPE = "video-type"; + + private static final String VALUE_VIDEO_TYPE_HTTP_PROGRESSIVE = "HTTP_PROGRESSIVE"; + private static final String VALUE_VIDEO_TYPE_HLS = "HLS"; + private static final String VALUE_VIDEO_TYPE_MPEG_DASH = "MPEG_DASH"; + + private static final String ANDROID_TV_RATING = "com.android.tv"; + + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss Z"); + + private XmlTvParser() { + } + + public static TvContentRating[] xmlTvRatingToTvContentRating( + XmlTvRating[] ratings) { + List list = new ArrayList<>(); + for (XmlTvRating rating : ratings) { + if (ANDROID_TV_RATING.equals(rating.system)) { + list.add(TvContentRating.unflattenFromString(rating.value)); + } + } + return list.toArray(new TvContentRating[list.size()]); + } + + public static TvListing parse(InputStream inputStream) { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setInput(inputStream, null); + int eventType = parser.next(); + if (eventType != XmlPullParser.START_TAG || !TAG_TV.equals(parser.getName())) { + throw new ParserException( + "inputStream does not contain a xml tv description"); + } + return parseTvListings(parser); + } catch (XmlPullParserException | IOException | ParseException e) { + e.printStackTrace(); + } + return null; + } + + private static TvListing parseTvListings(XmlPullParser parser) + throws IOException, XmlPullParserException, ParseException { + List channels = new ArrayList<>(); + List programs = new ArrayList<>(); + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() == XmlPullParser.START_TAG + && TAG_CHANNEL.equalsIgnoreCase(parser.getName())) { + channels.add(parseChannel(parser)); + } + if (parser.getEventType() == XmlPullParser.START_TAG + && TAG_PROGRAM.equalsIgnoreCase(parser.getName())) { + programs.add(parseProgram(parser)); + } + } + return new TvListing(channels, programs); + } + + private static XmlTvChannel parseChannel(XmlPullParser parser) + throws IOException, XmlPullParserException { + String id = null; + boolean repeatPrograms = false; + for (int i = 0; i < parser.getAttributeCount(); ++i) { + String attr = parser.getAttributeName(i); + String value = parser.getAttributeValue(i); + if (ATTR_ID.equalsIgnoreCase(attr)) { + id = value; + } else if (ATTR_REPEAT_PROGRAMS.equalsIgnoreCase(attr)) { + repeatPrograms = "TRUE".equalsIgnoreCase(value); + } + } + String displayName = null; + String displayNumber = null; + XmlTvIcon icon = null; + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() == XmlPullParser.START_TAG) { + if (TAG_DISPLAY_NAME.equalsIgnoreCase(parser.getName()) + && displayName == null) { + // TODO: support multiple display names. + displayName = parser.nextText(); + } else if (TAG_DISPLAY_NUMBER.equalsIgnoreCase(parser.getName()) + && displayNumber == null) { + displayNumber = parser.nextText(); + } else if (TAG_ICON.equalsIgnoreCase(parser.getName()) && icon == null) { + icon = parseIcon(parser); + } + } else if (TAG_CHANNEL.equalsIgnoreCase(parser.getName()) + && parser.getEventType() == XmlPullParser.END_TAG) { + break; + } + } + if (TextUtils.isEmpty(id) || TextUtils.isEmpty(displayName)) { + throw new IllegalArgumentException("id and display-name can not be null."); + } + + // Developers should assign original network ID in the right way not using the fake ID. + int fakeOriginalNetworkId = (displayNumber + displayName).hashCode(); + return new XmlTvChannel(id, displayName, displayNumber, icon, fakeOriginalNetworkId, 0, 0, + repeatPrograms); + } + + private static XmlTvProgram parseProgram(XmlPullParser parser) + throws IOException, XmlPullParserException, ParseException { + String channelId = null; + Long startTimeUtcMillis = null; + Long endTimeUtcMillis = null; + String videoSrc = null; + int videoType = PlaybackInfo.VIDEO_TYPE_HLS; + for (int i = 0; i < parser.getAttributeCount(); ++i) { + String attr = parser.getAttributeName(i); + String value = parser.getAttributeValue(i); + if (ATTR_CHANNEL.equalsIgnoreCase(attr)) { + channelId = value; + } else if (ATTR_START.equalsIgnoreCase(attr)) { + startTimeUtcMillis = DATE_FORMAT.parse(value).getTime(); + } else if (ATTR_STOP.equalsIgnoreCase(attr)) { + endTimeUtcMillis = DATE_FORMAT.parse(value).getTime(); + } else if (ATTR_VIDEO_SRC.equalsIgnoreCase(attr)) { + videoSrc = value; + } else if (ATTR_VIDEO_TYPE.equalsIgnoreCase(attr)) { + if (VALUE_VIDEO_TYPE_HTTP_PROGRESSIVE.equals(value)) { + videoType = PlaybackInfo.VIDEO_TYPE_HTTP_PROGRESSIVE; + } else if (VALUE_VIDEO_TYPE_HLS.equals(value)) { + videoType = PlaybackInfo.VIDEO_TYPE_HLS; + } else if (VALUE_VIDEO_TYPE_MPEG_DASH.equals(value)) { + videoType = PlaybackInfo.VIDEO_TYPE_MPEG_DASH; + } + } + } + String title = null; + String description = null; + XmlTvIcon icon = null; + List category = new ArrayList<>(); + List rating = new ArrayList<>(); + while (parser.next() != XmlPullParser.END_DOCUMENT) { + String tagName = parser.getName(); + if (parser.getEventType() == XmlPullParser.START_TAG) { + if (TAG_TITLE.equalsIgnoreCase(parser.getName())) { + title = parser.nextText(); + } else if (TAG_DESC.equalsIgnoreCase(tagName)) { + description = parser.nextText(); + } else if (TAG_ICON.equalsIgnoreCase(tagName)) { + icon = parseIcon(parser); + } else if (TAG_CATEGORY.equalsIgnoreCase(tagName)) { + category.add(parser.nextText()); + } else if (TAG_RATING.equalsIgnoreCase(tagName)) { + try { + rating.add(parseRating(parser)); + } catch (IllegalArgumentException e) { + // do not add wrong rating values + } + } + } else if (TAG_PROGRAM.equalsIgnoreCase(tagName) + && parser.getEventType() == XmlPullParser.END_TAG) { + break; + } + } + if (TextUtils.isEmpty(channelId) || startTimeUtcMillis == null + || endTimeUtcMillis == null) { + throw new IllegalArgumentException("channel, start, and end can not be null."); + } + return new XmlTvProgram(channelId, title, description, icon, + category.toArray(new String[category.size()]), startTimeUtcMillis, endTimeUtcMillis, + rating.toArray(new XmlTvRating[rating.size()]), videoSrc, videoType); + } + + private static XmlTvIcon parseIcon(XmlPullParser parser) + throws IOException, XmlPullParserException { + String src = null; + for (int i = 0; i < parser.getAttributeCount(); ++i) { + String attr = parser.getAttributeName(i); + String value = parser.getAttributeValue(i); + if (ATTR_SRC.equalsIgnoreCase(attr)) { + src = value; + } + } + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (TAG_ICON.equalsIgnoreCase(parser.getName()) + && parser.getEventType() == XmlPullParser.END_TAG) { + break; + } + } + if (TextUtils.isEmpty(src)) { + throw new IllegalArgumentException("src cannot be null."); + } + return new XmlTvIcon(src); + } + + private static XmlTvRating parseRating(XmlPullParser parser) + throws IOException, XmlPullParserException { + String system = null; + for (int i = 0; i < parser.getAttributeCount(); ++i) { + String attr = parser.getAttributeName(i); + String value = parser.getAttributeValue(i); + if (ATTR_SYSTEM.equalsIgnoreCase(attr)) { + system = value; + } + } + String value = null; + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() == XmlPullParser.START_TAG) { + if (TAG_VALUE.equalsIgnoreCase(parser.getName())) { + value = parser.nextText(); + } + } else if (TAG_RATING.equalsIgnoreCase(parser.getName()) + && parser.getEventType() == XmlPullParser.END_TAG) { + break; + } + } + if (TextUtils.isEmpty(system) || TextUtils.isEmpty(value)) { + throw new IllegalArgumentException("system and value cannot be null."); + } + return new XmlTvRating(system, value); + } + + public static class TvListing { + public List channels; + public final List programs; + + public TvListing(List channels, List programs) { + this.channels = channels; + this.programs = programs; + } + + public void setChannels(List channels) { + this.channels = channels; + } + } + + public static class XmlTvChannel { + public final String id; + public final String displayName; + public final String displayNumber; + public final XmlTvIcon icon; + public final int originalNetworkId; + public final int transportStreamId; + public final int serviceId; + public final boolean repeatPrograms; + public String url; + + public XmlTvChannel(String id, String displayName, String displayNumber, XmlTvIcon icon, + int originalNetworkId, int transportStreamId, int serviceId, + boolean repeatPrograms) { + this(id, displayName, displayNumber, icon, originalNetworkId, transportStreamId, + serviceId, repeatPrograms, null); + } + + public XmlTvChannel(String id, String displayName, String displayNumber, XmlTvIcon icon, + int originalNetworkId, int transportStreamId, int serviceId, + boolean repeatPrograms, String url) { + this.id = id; + this.displayName = displayName; + this.displayNumber = displayNumber; + this.icon = icon; + this.originalNetworkId = originalNetworkId; + this.transportStreamId = transportStreamId; + this.serviceId = serviceId; + this.repeatPrograms = repeatPrograms; + this.url = url; + } + } + + public static class XmlTvProgram { + public final String channelId; + public final String title; + public final String description; + public final XmlTvIcon icon; + public final String[] category; + public final long startTimeUtcMillis; + public final long endTimeUtcMillis; + public final XmlTvRating[] rating; + public final String videoSrc; + public final int videoType; + + private XmlTvProgram(String channelId, String title, String description, XmlTvIcon icon, + String[] category, long startTimeUtcMillis, long endTimeUtcMillis, + XmlTvRating[] rating, String videoSrc, int videoType) { + this.channelId = channelId; + this.title = title; + this.description = description; + this.icon = icon; + this.category = category; + this.startTimeUtcMillis = startTimeUtcMillis; + this.endTimeUtcMillis = endTimeUtcMillis; + this.rating = rating; + this.videoSrc = videoSrc; + this.videoType = videoType; + } + + public long getDurationMillis() { + return endTimeUtcMillis - startTimeUtcMillis; + } + } + + public static class XmlTvIcon { + public final String src; + + public XmlTvIcon(String src) { + this.src = src; + } + } + + public static class XmlTvRating { + public final String system; + public final String value; + + public XmlTvRating(String system, String value) { + this.system = system; + this.value = value; + } + } +} diff --git a/app/src/main/res/drawable-mdpi/default_background.xml b/app/src/main/res/drawable-mdpi/default_background.xml new file mode 100644 index 0000000..07b0589 --- /dev/null +++ b/app/src/main/res/drawable-mdpi/default_background.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..359047dfa4ed206e41e2354f9c6b307e713efe32 GIT binary patch literal 5237 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..71c6d760f05183ef8a47c614d8d13380c8528499 GIT binary patch literal 14383 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/overlay_view.xml b/app/src/main/res/layout/overlay_view.xml new file mode 100644 index 0000000..91ec5f9 --- /dev/null +++ b/app/src/main/res/layout/overlay_view.xml @@ -0,0 +1,33 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/setup_activity.xml b/app/src/main/res/layout/setup_activity.xml new file mode 100644 index 0000000..8c9cc71 --- /dev/null +++ b/app/src/main/res/layout/setup_activity.xml @@ -0,0 +1,21 @@ + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..cde69bcccec65160d92116f20ffce4fce0b5245c GIT binary patch literal 3418 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..bfa42f0e7b91d006d22352c9ff2f134e504e3c1d GIT binary patch literal 4842 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..324e72cdd7480cb983fa1bcc7ce686e51ef87fe7 GIT binary patch literal 7718 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..e9e5c7f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,18 @@ + + #000000 + #DDDDDD + #0096a6 + #ffaa3f + #ffaa3f + #0096a6 + #30000000 + #30FF0000 + #00000000 + #AA000000 + #59000000 + #FFFFFF + #AAFADCA7 + #FADCA7 + #EEFF41 + #3d3d3d + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4c8025f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + IPTV Live Channels + IPTV Live Channels + + http://tv.iptv.ink/iptv.ink + http://epg.iptv.ink/iptv.epg.gz + http://logo.iptv.ink/ + + Add Channels + Update Channels + Cancel Setup + In Progress… + TV Inputs by Your Company + No feed. Check your connection! + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..49d6086 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..cc8a824 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/main/res/xml/authenticator.xml new file mode 100644 index 0000000..ab47229 --- /dev/null +++ b/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,20 @@ + + + diff --git a/app/src/main/res/xml/syncadapter.xml b/app/src/main/res/xml/syncadapter.xml new file mode 100644 index 0000000..d739584 --- /dev/null +++ b/app/src/main/res/xml/syncadapter.xml @@ -0,0 +1,22 @@ + + + diff --git a/app/src/main/res/xml/tvinputservice.xml b/app/src/main/res/xml/tvinputservice.xml new file mode 100644 index 0000000..6e07bb3 --- /dev/null +++ b/app/src/main/res/xml/tvinputservice.xml @@ -0,0 +1,18 @@ + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..be515a8 --- /dev/null +++ b/build.gradle @@ -0,0 +1,23 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:1.3.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1d3591c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,18 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app'