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 0000000..359047d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher.png differ 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 0000000..71c6d76 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher.png differ 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 0000000..cde69bc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c133a0c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ 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 0000000..bfa42f0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 0000000..324e72c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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'