Added first implementation of the Timeline class

This commit is contained in:
daniel oeh 2014-06-29 02:38:25 +02:00
parent 2633d17046
commit c9c69aa7c7
4 changed files with 304 additions and 0 deletions

View File

@ -78,5 +78,26 @@ public final class Converter {
return String.format("%02d:%02d", h, m);
}
/** Converts long duration string (HH:MM:SS) to milliseconds. */
public static long durationStringLongToMs(String input) {
String[] parts = input.split(":");
if (parts.length != 3) {
return 0;
}
return Long.valueOf(parts[0]) * 3600 * 1000 +
Long.valueOf(parts[1]) * 60 * 1000 +
Long.valueOf(parts[2]) * 1000;
}
/** Converts short duration string (HH:MM) to milliseconds. */
public static long durationStringShortToMs(String input) {
String[] parts = input.split(":");
if (parts.length != 2) {
return 0;
}
return Long.valueOf(parts[0]) * 3600 * 1000 +
Long.valueOf(parts[1]) * 1000 * 60;
}
}

View File

@ -0,0 +1,152 @@
package de.danoeh.antennapod.util.playback;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.Log;
import android.util.TypedValue;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.danoeh.antennapod.BuildConfig;
import de.danoeh.antennapod.util.Converter;
import de.danoeh.antennapod.util.ShownotesProvider;
/**
* Connects chapter information and shownotes of a shownotesProvider, for example by making it possible to use the
* shownotes to navigate to another position in the podcast or by highlighting certain parts of the shownotesProvider's
* shownotes.
* <p/>
* A timeline object needs a shownotesProvider from which the chapter information is retrieved and shownotes are generated.
*/
public class Timeline {
private static final String TAG = "Timeline";
private static final String WEBVIEW_STYLE = "<style type=\"text/css\"> @font-face { font-family: 'Roboto-Light'; src: url('file:///android_asset/Roboto-Light.ttf'); } * { color: %s; font-family: roboto-Light; font-size: 11pt; } a { font-style: normal; text-decoration: none; font-weight: normal; color: #00A8DF; } img { display: block; margin: 10 auto; max-width: %s; height: auto; } body { margin: %dpx %dpx %dpx %dpx; }";
private ShownotesProvider shownotesProvider;
private final String colorString;
private final int pageMargin;
public Timeline(Context context, ShownotesProvider shownotesProvider) {
if (shownotesProvider == null) throw new IllegalArgumentException("shownotesProvider = null");
this.shownotesProvider = shownotesProvider;
TypedArray res = context
.getTheme()
.obtainStyledAttributes(
new int[]{android.R.attr.textColorPrimary});
int colorResource = res.getColor(0, 0);
colorString = String.format("#%06X",
0xFFFFFF & colorResource);
res.recycle();
pageMargin = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 8, context.getResources()
.getDisplayMetrics()
);
}
private static final Pattern TIMECODE_LINK_REGEX = Pattern.compile("antennapod://timecode/((\\d+))");
private static final String TIMECODE_LINK = "<a href=\"antennapod://timecode/%d\">%s</a>";
private static final Pattern TIMECODE_REGEX = Pattern.compile("\\b(?:(?:(([0-9][0-9])):))?(([0-9][0-9])):(([0-9][0-9]))\\b");
/**
* Applies an app-specific CSS stylesheet and adds timecode links (optional).
* <p/>
* This method does NOT change the original shownotes string of the shownotesProvider object and it should
* also not be changed by the caller.
*
* @param addTimecodes True if this method should add timecode links
* @return The processed HTML string.
*/
public String processShownotes(final boolean addTimecodes) {
// load shownotes
String shownotes;
try {
shownotes = shownotesProvider.loadShownotes().call();
} catch (Exception e) {
e.printStackTrace();
return null;
}
if (shownotes == null) {
if (BuildConfig.DEBUG)
Log.d(TAG, "shownotesProvider contained no shownotes. Returning empty string");
return "";
}
Document document = Jsoup.parse(shownotes);
// apply style
String styleStr = String.format(WEBVIEW_STYLE, colorString, "100%", pageMargin,
pageMargin, pageMargin, pageMargin);
document.head().appendElement("style").attr("type", "text/css").text(styleStr);
// apply timecode links
if (addTimecodes) {
Elements elementsWithTimeCodes = document.body().getElementsMatchingOwnText(TIMECODE_REGEX);
if (BuildConfig.DEBUG)
Log.d(TAG, "Recognized " + elementsWithTimeCodes.size() + " timecodes");
for (Element element : elementsWithTimeCodes) {
Matcher matcherLong = TIMECODE_REGEX.matcher(element.text());
StringBuffer buffer = new StringBuffer();
while (matcherLong.find()) {
String h = matcherLong.group(1);
String group = matcherLong.group(0);
long time = (h != null) ? Converter.durationStringLongToMs(group) :
Converter.durationStringShortToMs(group);
String rep = String.format(TIMECODE_LINK, time, group);
matcherLong.appendReplacement(buffer, rep);
}
element.html(buffer.toString());
}
}
Log.i(TAG, "Out: " + document.toString());
return document.toString();
}
/**
* Returns true if the given link is a timecode link.
*/
public static boolean isTimecodeLink(String link) {
return link != null && link.matches(TIMECODE_LINK_REGEX.pattern());
}
/**
* Returns the time in milliseconds that is attached to this link or -1
* if the link is no valid timecode link.
*/
public static long getTimecodeLinkTime(String link) {
if (isTimecodeLink(link)) {
Matcher m = TIMECODE_LINK_REGEX.matcher(link);
try {
if (m.find()) {
return Long.valueOf(m.group(1));
}
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
return -1;
}
public void setShownotesProvider(ShownotesProvider shownotesProvider) {
if (shownotesProvider == null) throw new IllegalArgumentException("shownotesProvider = null");
this.shownotesProvider = shownotesProvider;
}
}

View File

@ -0,0 +1,35 @@
package instrumentationTest.de.test.antennapod.util;
import android.test.AndroidTestCase;
import de.danoeh.antennapod.util.Converter;
/**
* Test class for converter
*/
public class ConverterTest extends AndroidTestCase {
public void testGetDurationStringLong() throws Exception {
String expected = "13:05:10";
int input = 47110000;
assertEquals(expected, Converter.getDurationStringLong(input));
}
public void testGetDurationStringShort() throws Exception {
String expected = "13:05";
int input = 47110000;
assertEquals(expected, Converter.getDurationStringShort(input));
}
public void testDurationStringLongToMs() throws Exception {
String input = "01:20:30";
long expected = 4830000;
assertEquals(expected, Converter.durationStringLongToMs(input));
}
public void testDurationStringShortToMs() throws Exception {
String input = "8:30";
long expected = 30600000;
assertEquals(expected, Converter.durationStringShortToMs(input));
}
}

View File

@ -0,0 +1,96 @@
package instrumentationTest.de.test.antennapod.util.playback;
import android.content.Context;
import android.test.InstrumentationTestCase;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.util.Date;
import java.util.List;
import de.danoeh.antennapod.feed.Chapter;
import de.danoeh.antennapod.feed.FeedItem;
import de.danoeh.antennapod.feed.FeedMedia;
import de.danoeh.antennapod.util.playback.Playable;
import de.danoeh.antennapod.util.playback.Timeline;
/**
* Test class for timeline
*/
public class TimelineTest extends InstrumentationTestCase {
private Context context;
@Override
public void setUp() throws Exception {
super.setUp();
context = getInstrumentation().getTargetContext();
}
private Playable newTestPlayable(List<Chapter> chapters, String shownotes) {
FeedItem item = new FeedItem(0, "Item", "item-id", "http://example.com/item", new Date(), true, null);
item.setChapters(chapters);
item.setContentEncoded(shownotes);
FeedMedia media = new FeedMedia(item, "http://example.com/episode", 100, "audio/mp3");
item.setMedia(media);
return media;
}
public void testProcessShownotesAddTimecodeHHMMSSNoChapters() throws Exception {
final String timeStr = "10:11:12";
final long time = 3600 * 1000 * 10 + 60 * 1000 * 11 + 12 * 1000;
Playable p = newTestPlayable(null, "<p> Some test text with a timecode " + timeStr + " here.</p>");
Timeline t = new Timeline(context, p);
String res = t.processShownotes(true);
checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
}
public void testProcessShownotesAddTimecodeHHMMNoChapters() throws Exception {
final String timeStr = "10:11";
final long time = 3600 * 1000 * 10 + 60 * 1000 * 11;
Playable p = newTestPlayable(null, "<p> Some test text with a timecode " + timeStr + " here.</p>");
Timeline t = new Timeline(context, p);
String res = t.processShownotes(true);
checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
}
private void checkLinkCorrect(String res, long[] timecodes, String[] timecodeStr) {
assertNotNull(res);
Document d = Jsoup.parse(res);
Elements links = d.body().getElementsByTag("a");
int countedLinks = 0;
for (Element link : links) {
String href = link.attributes().get("href");
String text = link.text();
if (href.startsWith("antennapod://")) {
assertTrue(href.endsWith(String.valueOf(timecodes[countedLinks])));
assertEquals(timecodeStr[countedLinks], text);
countedLinks++;
assertTrue("Contains too many links: " + countedLinks + " > " + timecodes.length, countedLinks <= timecodes.length);
}
}
assertEquals(timecodes.length, countedLinks);
}
public void testIsTimecodeLink() throws Exception {
assertFalse(Timeline.isTimecodeLink(null));
assertFalse(Timeline.isTimecodeLink("http://antennapod/timecode/123123"));
assertFalse(Timeline.isTimecodeLink("antennapod://timecode/"));
assertFalse(Timeline.isTimecodeLink("antennapod://123123"));
assertFalse(Timeline.isTimecodeLink("antennapod://timecode/123123a"));
assertTrue(Timeline.isTimecodeLink("antennapod://timecode/123"));
assertTrue(Timeline.isTimecodeLink("antennapod://timecode/1"));
}
public void testGetTimecodeLinkTime() throws Exception {
assertEquals(-1, Timeline.getTimecodeLinkTime(null));
assertEquals(-1, Timeline.getTimecodeLinkTime("http://timecode/123"));
assertEquals(123, Timeline.getTimecodeLinkTime("antennapod://timecode/123"));
}
}