package com.felkertech.cumulustv.fileio;
import android.graphics.Color;
import android.media.tv.TvContentRating;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.Log;
import android.util.Xml;
import com.google.android.media.tv.companionlibrary.model.Advertisement;
import com.google.android.media.tv.companionlibrary.model.Channel;
import com.google.android.media.tv.companionlibrary.model.InternalProviderData;
import com.google.android.media.tv.companionlibrary.model.Program;
import com.google.android.media.tv.companionlibrary.utils.TvContractUtils;
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.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
/**
* XMLTV document parser which conforms to http://wiki.xmltv.org/index.php/Main_Page
* </p>
* Please note that xmltv.dtd are extended to be align with Android TV Input Framework and
* contain static video contents:
* </p>
* <!ELEMENT channel ([elements in xmltv.dtd], display-number, app-link) > <!ATTLIST channel
* [attributes in xmltv.dtd] repeat-programs CDATA #IMPLIED > <!ATTLIST programme [attributes in
* xmltv.dtd] video-src CDATA #IMPLIED video-type CDATA #IMPLIED > <!ELEMENT app-link (icon) >
* <!ATTLIST app-link text CDATA #IMPLIED color CDATA #IMPLIED poster-uri CDATA #IMPLIED intent-uri
* CDATA #IMPLIED > <!ELEMENT advertisement > <!ATTLIST start stop type >
* </p>
* display-number : The channel number that is displayed to the user.
* </p>
* repeat-programs : If "true", the programs in the xml document are scheduled sequentially in a
* loop. Program and advertisement start and end times will be shifted as necessary for looping
* content. This is introduced to simulate a live channel in this sample.
* </p>
* 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.
* </p>
* video-type : The video type. Should be one of "HTTP_PROGRESSIVE", "HLS", or "MPEG-DASH". This can
* be omitted if the xml will be used only for the program guide update.
* </p>
* app-link : The app-link allows channel input sources to provide activity links from their live
* channel programming to another activity. This enables content providers to increase user
* engagement by offering the viewer other content or actions.
* </p>
* text : The text of the app link template for this channel.
* </p>
* color : The accent color of the app link template for this channel. This is primarily
* used for the background color of the text box in the template.
* </p>
* poster-uri : The URI for the poster art used as the background of the app link template
* for this channel.
* </p>
* intent-uri : The intent URI of the app link for this channel. It should be created using
* Intent.toUri(int) with Intent.URI_INTENT_SCHEME. (see https://developer.android.com/reference/android/media/tv/TvContract.Channels.html#COLUMN_APP_LINK_INTENT_URI)
* The intent is launched when the user clicks the corresponding app link for the current channel.
* </p>
* advertisement : Representing an advertisement that can play on a channel or during a program.
* </p>
* type : The type of advertisement. Requires "VAST".
* </p>
* start : The start time of the advertisement.
* </p>
* stop : The stop time of the advertisement.
* </p>
* request-url : This element should contain the URL for the advertisement.
* </p>
*/
public class CumulusXmlParser {
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_APP_LINK = "app-link";
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 TAG_AD = "advertisement";
private static final String TAG_REQUEST_URL = "request-url";
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 ATTR_APP_LINK_TEXT = "text";
private static final String ATTR_APP_LINK_COLOR = "color";
private static final String ATTR_APP_LINK_POSTER_URI = "poster-uri";
private static final String ATTR_APP_LINK_INTENT_URI = "intent-uri";
private static final String ATTR_AD_START = "start";
private static final String ATTR_AD_STOP = "stop";
private static final String ATTR_AD_TYPE = "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 VALUE_ADVERTISEMENT_TYPE_VAST = "VAST";
private static final String ANDROID_TV_RATING = "com.android.tv";
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss Z",
Locale.US);
private static final String TAG = "XmlTvParser";
private CumulusXmlParser() {
}
/**
* Converts a TV ratings from an XML file to {@link TvContentRating}.
*
* @param rating An XmlTvRating.
* @return A TvContentRating.
*/
private static TvContentRating xmlTvRatingToTvContentRating(
CumulusXmlParser.XmlTvRating rating) {
if (ANDROID_TV_RATING.equals(rating.system)) {
return TvContentRating.unflattenFromString(rating.value);
}
return null;
}
/**
* Reads an InputStream and parses the data to identify channels and programs
*
* @param inputStream The InputStream of your data
* @return A TvListing containing your channels and programs
*/
public static TvListing parse(@NonNull InputStream inputStream) throws XmlTvParseException {
return parse(inputStream, Xml.newPullParser());
}
/**
* Reads an InputStream and parses the data to identify channels and programs
*
* @param inputStream The InputStream of your data
* @param parser The XmlPullParser the developer selects to parse this data
* @return A TvListing containing your channels and programs
*/
private static TvListing parse(@NonNull InputStream inputStream, @NonNull XmlPullParser parser)
throws XmlTvParseException {
try {
parser.setInput(inputStream, null);
int eventType = parser.next();
if (eventType != XmlPullParser.START_TAG || !TAG_TV.equals(parser.getName())) {
throw new XmlTvParseException(
"Input stream does not contain an XMLTV description");
}
return parseTvListings(parser);
} catch (XmlPullParserException | IOException | ParseException e) {
Log.w(TAG, e.getMessage());
}
return null;
}
private static TvListing parseTvListings(XmlPullParser parser)
throws IOException, XmlPullParserException, ParseException {
List<Channel> channels = new ArrayList<>();
List<Program> 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 Channel parseChannel(XmlPullParser parser)
throws IOException, XmlPullParserException, ParseException {
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;
XmlTvAppLink appLink = null;
Advertisement advertisement = null;
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.getEventType() == XmlPullParser.START_TAG) {
if (TAG_DISPLAY_NAME.equalsIgnoreCase(parser.getName())
&& displayName == null) {
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_APP_LINK.equalsIgnoreCase(parser.getName()) && appLink == null) {
appLink = parseAppLink(parser);
} else if (TAG_AD.equalsIgnoreCase(parser.getName()) && advertisement == null) {
advertisement = parseAd(parser, TAG_CHANNEL);
}
} 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.
InternalProviderData internalProviderData = new InternalProviderData();
internalProviderData.setRepeatable(repeatPrograms);
Channel.Builder builder = new Channel.Builder()
.setDisplayName(displayName)
.setDisplayNumber(displayNumber)
.setOriginalNetworkId(id.hashCode())
.setInternalProviderData(internalProviderData)
.setTransportStreamId(0)
.setServiceId(0);
if (icon != null) {
builder.setChannelLogo(icon.src);
}
if (appLink != null) {
builder.setAppLinkColor(appLink.color)
.setAppLinkIconUri(appLink.icon.src)
.setAppLinkIntentUri(appLink.intentUri)
.setAppLinkPosterArtUri(appLink.posterUri)
.setAppLinkText(appLink.text);
}
if (advertisement != null) {
List<Advertisement> advertisements = new ArrayList<>(1);
advertisements.add(advertisement);
internalProviderData.setAds(advertisements);
builder.setInternalProviderData(internalProviderData);
}
return builder.build();
}
private static Program parseProgram(XmlPullParser parser)
throws IOException, XmlPullParserException, ParseException {
String channelId = null;
Long startTimeUtcMillis = null;
Long endTimeUtcMillis = null;
String videoSrc = null;
int videoType = TvContractUtils.SOURCE_TYPE_HTTP_PROGRESSIVE;
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 = TvContractUtils.SOURCE_TYPE_HTTP_PROGRESSIVE;
} else if (VALUE_VIDEO_TYPE_HLS.equals(value)) {
videoType = TvContractUtils.SOURCE_TYPE_HLS;
} else if (VALUE_VIDEO_TYPE_MPEG_DASH.equals(value)) {
videoType = TvContractUtils.SOURCE_TYPE_MPEG_DASH;
}
}
}
String title = null;
String description = null;
XmlTvIcon icon = null;
List<String> category = new ArrayList<>();
List<TvContentRating> rating = new ArrayList<>();
List<Advertisement> ads = 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)) {
TvContentRating xmlTvRating = xmlTvRatingToTvContentRating(parseRating(parser));
if (xmlTvRating != null)
rating.add(xmlTvRating);
} else if (TAG_AD.equalsIgnoreCase(tagName)) {
ads.add(parseAd(parser, TAG_PROGRAM));
}
} 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.");
}
InternalProviderData internalProviderData = new InternalProviderData();
internalProviderData.setVideoType(videoType);
internalProviderData.setVideoUrl(videoSrc);
internalProviderData.setAds(ads);
return new Program.Builder()
.setChannelId(channelId.hashCode())
.setTitle(title)
.setDescription(description)
.setPosterArtUri(icon != null ? icon.src : null)
.setCanonicalGenres(category.toArray(new String[category.size()]))
.setStartTimeUtcMillis(startTimeUtcMillis)
.setEndTimeUtcMillis(endTimeUtcMillis)
.setContentRatings(rating.toArray(new TvContentRating[rating.size()]))
// 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(internalProviderData)
.build();
}
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("Icon src cannot be null.");
}
return new XmlTvIcon(src);
}
private static XmlTvAppLink parseAppLink(XmlPullParser parser)
throws IOException, XmlPullParserException {
String text = null;
Integer color = null;
String posterUri = null;
String intentUri = null;
for (int i = 0; i < parser.getAttributeCount(); ++i) {
String attr = parser.getAttributeName(i);
String value = parser.getAttributeValue(i);
if (ATTR_APP_LINK_TEXT.equalsIgnoreCase(attr)) {
text = value;
} else if (ATTR_APP_LINK_COLOR.equalsIgnoreCase(attr)) {
color = Color.parseColor(value);
} else if (ATTR_APP_LINK_POSTER_URI.equalsIgnoreCase(attr)) {
posterUri = value;
} else if (ATTR_APP_LINK_INTENT_URI.equalsIgnoreCase(attr)) {
intentUri = value;
}
}
XmlTvIcon icon = null;
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.getEventType() == XmlPullParser.START_TAG
&& TAG_ICON.equalsIgnoreCase(parser.getName()) && icon == null) {
icon = parseIcon(parser);
} else if (TAG_APP_LINK.equalsIgnoreCase(parser.getName())
&& parser.getEventType() == XmlPullParser.END_TAG) {
break;
}
}
return new XmlTvAppLink(text, color, posterUri, intentUri, icon);
}
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);
}
private static Advertisement parseAd(XmlPullParser parser, String adType)
throws IOException, XmlPullParserException, ParseException{
Long startTimeUtcMillis = null;
Long stopTimeUtcMillis = null;
int type = Advertisement.TYPE_VAST;
for (int i = 0; i < parser.getAttributeCount(); ++i) {
String attr = parser.getAttributeName(i);
String value = parser.getAttributeValue(i);
if (ATTR_AD_START.equalsIgnoreCase(attr)) {
startTimeUtcMillis = DATE_FORMAT.parse(value).getTime();
} else if (ATTR_AD_STOP.equalsIgnoreCase(attr)) {
stopTimeUtcMillis = DATE_FORMAT.parse(value).getTime();
} else if (ATTR_AD_TYPE.equalsIgnoreCase(attr)) {
if (VALUE_ADVERTISEMENT_TYPE_VAST.equalsIgnoreCase(attr)) {
type = Advertisement.TYPE_VAST;
}
}
}
String requestUrl = null;
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.getEventType() == XmlPullParser.START_TAG) {
if (TAG_REQUEST_URL.equalsIgnoreCase(parser.getName())) {
requestUrl = parser.nextText();
}
} else if (TAG_AD.equalsIgnoreCase(parser.getName())
&& parser.getEventType() == XmlPullParser.END_TAG) {
break;
}
}
Advertisement.Builder builder = new Advertisement.Builder();
if (adType.equals(TAG_PROGRAM)) {
if (startTimeUtcMillis == null || stopTimeUtcMillis == null) {
throw new IllegalArgumentException(
"start, stop time of program ads cannot be null");
}
builder.setStartTimeUtcMillis(startTimeUtcMillis);
builder.setStopTimeUtcMillis(stopTimeUtcMillis);
}
return builder.setType(type).setRequestUrl(requestUrl).build();
}
/**
* Contains a list of channels and corresponding programs that have been generated from parsing
* an XML TV file.
*/
public static class TvListing {
private List<Channel> mChannels;
private List<Program> mPrograms;
private HashMap<Integer, List<Program>> mProgramMap;
private TvListing(List<Channel> channels, List<Program> programs) {
this.mChannels = channels;
this.mPrograms = new ArrayList<>(programs);
// Place programs into the epg map
mProgramMap = new HashMap<>();
for (Channel channel: channels) {
List<Program> programsForChannel = new ArrayList<>();
Iterator<Program> programIterator = programs.iterator();
while (programIterator.hasNext()) {
Program program = programIterator.next();
if (program.getChannelId() == channel.getOriginalNetworkId()) {
programsForChannel.add(new Program.Builder(program)
.setChannelId(channel.getId())
.build());
programIterator.remove();
}
}
mProgramMap.put(channel.getOriginalNetworkId(), programsForChannel);
}
}
/**
* @return All channels found by the XmlTvParser.
*/
public List<Channel> getChannels() {
return mChannels;
}
/**
* @return All programs found by the XmlTvParser.
*/
public List<Program> getAllPrograms() {
return mPrograms;
}
/**
* Returns a list of programs found by the XmlTvParser for a given channel.
* @param channel The channel to obtain programs for.
* @return A list of programs that belong to that channel.
*/
public List<Program> getPrograms(Channel channel) {
return mProgramMap.get(channel.getOriginalNetworkId());
}
}
private static class XmlTvIcon {
public final String src;
private XmlTvIcon(String src) {
this.src = src;
}
}
private static class XmlTvRating {
public final String system;
public final String value;
public XmlTvRating(String system, String value) {
this.system = system;
this.value = value;
}
}
private static class XmlTvAppLink {
public final String text;
public final Integer color;
public final String posterUri;
public final String intentUri;
public final XmlTvIcon icon;
public XmlTvAppLink(String text, Integer color, String posterUri, String intentUri,
XmlTvIcon icon) {
this.text = text;
this.color = color;
this.posterUri = posterUri;
this.intentUri = intentUri;
this.icon = icon;
}
}
/**
* An exception that indicates the provided XMLTV file is invalid or improperly formatted.
*/
public static class XmlTvParseException extends Exception {
public XmlTvParseException(String msg) {
super(msg);
}
}
}