diff options
author | daniel oeh <daniel.oeh@gmail.com> | 2014-09-17 20:51:45 +0200 |
---|---|---|
committer | daniel oeh <daniel.oeh@gmail.com> | 2014-09-17 20:51:45 +0200 |
commit | 072639b5b22e816df9f78b5cd8a7d4e5379b6aff (patch) | |
tree | 341c574bd6eb64497470e7226b3222b0a7c5a824 /app/src/main/java/de | |
parent | 76add8ef68dbc9997e901f4c11c397f581e8eabe (diff) | |
download | AntennaPod-072639b5b22e816df9f78b5cd8a7d4e5379b6aff.zip |
Changed project structure
Switched from custom layout to standard gradle project structure
Diffstat (limited to 'app/src/main/java/de')
207 files changed, 33556 insertions, 0 deletions
diff --git a/app/src/main/java/de/danoeh/antennapod/AppConfig.java b/app/src/main/java/de/danoeh/antennapod/AppConfig.java new file mode 100644 index 000000000..7a75e3a18 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/AppConfig.java @@ -0,0 +1,7 @@ +package de.danoeh.antennapod; + +public final class AppConfig { + /** Should be used when setting User-Agent header for HTTP-requests. */ + public final static String USER_AGENT = "AntennaPod/0.9.9.3"; + +} diff --git a/app/src/main/java/de/danoeh/antennapod/PodcastApp.java b/app/src/main/java/de/danoeh/antennapod/PodcastApp.java new file mode 100644 index 000000000..74628f3d6 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/PodcastApp.java @@ -0,0 +1,47 @@ +package de.danoeh.antennapod; + +import android.app.Application; +import android.content.res.Configuration; +import android.util.Log; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.preferences.PlaybackPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.spa.SPAUtil; + +/** Main application class. */ +public class PodcastApp extends Application { + + private static final String TAG = "PodcastApp"; + public static final String EXPORT_DIR = "export/"; + + private static float LOGICAL_DENSITY; + + private static PodcastApp singleton; + + public static PodcastApp getInstance() { + return singleton; + } + + @Override + public void onCreate() { + super.onCreate(); + singleton = this; + LOGICAL_DENSITY = getResources().getDisplayMetrics().density; + + UserPreferences.createInstance(this); + PlaybackPreferences.createInstance(this); + EventDistributor.getInstance(); + + SPAUtil.sendSPAppsQueryFeedsIntent(this); + } + + public static float getLogicalDensity() { + return LOGICAL_DENSITY; + } + + public boolean isLargeScreen() { + return (getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_LARGE + || (getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE; + + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java new file mode 100644 index 000000000..cf7de1709 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java @@ -0,0 +1,32 @@ +package de.danoeh.antennapod.activity; + +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import de.danoeh.antennapod.R; + +/** Displays the 'about' screen */ +public class AboutActivity extends ActionBarActivity { + + private WebView webview; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getSupportActionBar().hide(); + setContentView(R.layout.about); + webview = (WebView) findViewById(R.id.webvAbout); + webview.setWebViewClient(new WebViewClient() { + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + view.loadUrl(url); + return false; + } + + }); + webview.loadUrl("file:///android_asset/about.html"); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java new file mode 100644 index 000000000..18d27ddda --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java @@ -0,0 +1,746 @@ +package de.danoeh.antennapod.activity; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.ActionBarDrawerToggle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.app.ListFragment; +import android.support.v4.widget.DrawerLayout; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView.ScaleType; +import android.widget.ListView; +import android.widget.TextView; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.adapter.ChapterListAdapter; +import de.danoeh.antennapod.adapter.NavListAdapter; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.dialog.VariableSpeedDialog; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.feed.SimpleChapter; +import de.danoeh.antennapod.fragment.CoverFragment; +import de.danoeh.antennapod.fragment.ItemDescriptionFragment; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; +import de.danoeh.antennapod.util.playback.ExternalMedia; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.util.playback.PlaybackController; + +/** + * Activity for playing audio files. + */ +public class AudioplayerActivity extends MediaplayerActivity implements ItemDescriptionFragment.ItemDescriptionFragmentCallback, + NavDrawerActivity { + private static final int POS_COVER = 0; + private static final int POS_DESCR = 1; + private static final int POS_CHAPTERS = 2; + private static final int NUM_CONTENT_FRAGMENTS = 3; + + final String TAG = "AudioplayerActivity"; + private static final String PREFS = "AudioPlayerActivityPreferences"; + private static final String PREF_KEY_SELECTED_FRAGMENT_POSITION = "selectedFragmentPosition"; + private static final String PREF_PLAYABLE_ID = "playableId"; + + private DrawerLayout drawerLayout; + private NavListAdapter navAdapter; + private ListView navList; + private ActionBarDrawerToggle drawerToggle; + + private Fragment[] detachedFragments; + + private CoverFragment coverFragment; + private ItemDescriptionFragment descriptionFragment; + private ListFragment chapterFragment; + + private Fragment currentlyShownFragment; + private int currentlyShownPosition = -1; + /** + * Used if onResume was called without loadMediaInfo. + */ + private int savedPosition = -1; + + private TextView txtvTitle; + private Button butPlaybackSpeed; + private ImageButton butNavLeft; + private ImageButton butNavRight; + + private void resetFragmentView() { + FragmentTransaction fT = getSupportFragmentManager().beginTransaction(); + + if (coverFragment != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Removing cover fragment"); + fT.remove(coverFragment); + } + if (descriptionFragment != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Removing description fragment"); + fT.remove(descriptionFragment); + } + if (chapterFragment != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Removing chapter fragment"); + fT.remove(chapterFragment); + } + if (currentlyShownFragment != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Removing currently shown fragment"); + fT.remove(currentlyShownFragment); + } + for (int i = 0; i < detachedFragments.length; i++) { + Fragment f = detachedFragments[i]; + if (f != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Removing detached fragment"); + fT.remove(f); + } + } + fT.commit(); + currentlyShownFragment = null; + coverFragment = null; + descriptionFragment = null; + chapterFragment = null; + currentlyShownPosition = -1; + detachedFragments = new Fragment[NUM_CONTENT_FRAGMENTS]; + } + + @Override + protected void onStop() { + super.onStop(); + if (BuildConfig.DEBUG) + Log.d(TAG, "onStop"); + cancelLoadTask(); + EventDistributor.getInstance().unregister(contentUpdate); + + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getSupportActionBar().setDisplayShowTitleEnabled(false); + detachedFragments = new Fragment[NUM_CONTENT_FRAGMENTS]; + } + + private void savePreferences() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Saving preferences"); + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + if (currentlyShownPosition >= 0 && controller != null + && controller.getMedia() != null) { + editor.putInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, + currentlyShownPosition); + editor.putString(PREF_PLAYABLE_ID, controller.getMedia() + .getIdentifier().toString()); + } else { + editor.putInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, -1); + editor.putString(PREF_PLAYABLE_ID, ""); + } + editor.commit(); + + savedPosition = currentlyShownPosition; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + drawerToggle.onConfigurationChanged(newConfig); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + // super.onSaveInstanceState(outState); would cause crash + if (BuildConfig.DEBUG) + Log.d(TAG, "onSaveInstanceState"); + + } + + @Override + protected void onPause() { + savePreferences(); + resetFragmentView(); + super.onPause(); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + restoreFromPreferences(); + } + + /** + * Tries to restore the selected fragment position from the Activity's + * preferences. + * + * @return true if restoreFromPrefernces changed the activity's state + */ + private boolean restoreFromPreferences() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Restoring instance state"); + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + int savedPosition = prefs.getInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, + -1); + String playableId = prefs.getString(PREF_PLAYABLE_ID, ""); + + if (savedPosition != -1 + && controller != null + && controller.getMedia() != null + && controller.getMedia().getIdentifier().toString() + .equals(playableId)) { + switchToFragment(savedPosition); + return true; + } else if (controller == null || controller.getMedia() == null) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Couldn't restore from preferences: controller or media was null"); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Couldn't restore from preferences: savedPosition was -1 or saved identifier and playable identifier didn't match.\nsavedPosition: " + + savedPosition + ", id: " + playableId + ); + + } + return false; + } + + @Override + protected void onResume() { + super.onResume(); + if (StringUtils.equals(getIntent().getAction(), Intent.ACTION_VIEW)) { + Intent intent = getIntent(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Received VIEW intent: " + + intent.getData().getPath()); + ExternalMedia media = new ExternalMedia(intent.getData().getPath(), + MediaType.AUDIO); + Intent launchIntent = new Intent(this, PlaybackService.class); + launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); + launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, + true); + launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, false); + launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, + true); + startService(launchIntent); + } + if (savedPosition != -1) { + switchToFragment(savedPosition); + } + + EventDistributor.getInstance().register(contentUpdate); + loadData(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + protected void onAwaitingVideoSurface() { + if (BuildConfig.DEBUG) + Log.d(TAG, "onAwaitingVideoSurface was called in audio player -> switching to video player"); + startActivity(new Intent(this, VideoplayerActivity.class)); + } + + @Override + protected void postStatusMsg(int resId) { + setSupportProgressBarIndeterminateVisibility(resId == R.string.player_preparing_msg + || resId == R.string.player_seeking_msg + || resId == R.string.player_buffering_msg); + } + + @Override + protected void clearStatusMsg() { + setSupportProgressBarIndeterminateVisibility(false); + } + + /** + * Changes the currently displayed fragment. + * + * @param pos Must be POS_COVER, POS_DESCR, or POS_CHAPTERS + */ + private void switchToFragment(int pos) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Switching contentView to position " + pos); + if (currentlyShownPosition != pos && controller != null) { + Playable media = controller.getMedia(); + if (media != null) { + FragmentTransaction ft = getSupportFragmentManager() + .beginTransaction(); + if (currentlyShownFragment != null) { + detachedFragments[currentlyShownPosition] = currentlyShownFragment; + ft.detach(currentlyShownFragment); + } + switch (pos) { + case POS_COVER: + if (coverFragment == null) { + Log.i(TAG, "Using new coverfragment"); + coverFragment = CoverFragment.newInstance(media); + } + currentlyShownFragment = coverFragment; + break; + case POS_DESCR: + if (descriptionFragment == null) { + descriptionFragment = ItemDescriptionFragment + .newInstance(media, true, true); + } + currentlyShownFragment = descriptionFragment; + break; + case POS_CHAPTERS: + if (chapterFragment == null) { + chapterFragment = new ListFragment() { + + @Override + public void onListItemClick(ListView l, View v, + int position, long id) { + super.onListItemClick(l, v, position, id); + Chapter chapter = (Chapter) this + .getListAdapter().getItem(position); + controller.seekToChapter(chapter); + } + + }; + chapterFragment.setListAdapter(new ChapterListAdapter( + AudioplayerActivity.this, 0, media + .getChapters(), media + )); + } + currentlyShownFragment = chapterFragment; + break; + } + if (currentlyShownFragment != null) { + currentlyShownPosition = pos; + if (detachedFragments[pos] != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Reattaching fragment at position " + + pos); + ft.attach(detachedFragments[pos]); + } else { + ft.add(R.id.contentView, currentlyShownFragment); + } + ft.disallowAddToBackStack(); + ft.commit(); + updateNavButtonDrawable(); + } + } + } + } + + private void updateNavButtonDrawable() { + + final int[] buttonTexts = new int[]{R.string.show_shownotes_label, + R.string.show_chapters_label, R.string.show_cover_label}; + + final TypedArray drawables = obtainStyledAttributes(new int[]{ + R.attr.navigation_shownotes, R.attr.navigation_chapters}); + final Playable media = controller.getMedia(); + if (butNavLeft != null && butNavRight != null && media != null) { + + butNavRight.setTag(R.id.imageloader_key, null); + butNavLeft.setTag(R.id.imageloader_key, null); + + switch (currentlyShownPosition) { + case POS_COVER: + butNavLeft.setScaleType(ScaleType.CENTER); + butNavLeft.setImageDrawable(drawables.getDrawable(0)); + butNavLeft.setContentDescription(getString(buttonTexts[0])); + + butNavRight.setImageDrawable(drawables.getDrawable(1)); + butNavRight.setContentDescription(getString(buttonTexts[1])); + + break; + case POS_DESCR: + butNavLeft.setScaleType(ScaleType.CENTER_CROP); + butNavLeft.post(new Runnable() { + + @Override + public void run() { + PicassoProvider.getMediaMetadataPicassoInstance(AudioplayerActivity.this) + .load(media.getImageUri()) + .fit() + .into(butNavLeft); + } + }); + butNavLeft.setContentDescription(getString(buttonTexts[2])); + + butNavRight.setImageDrawable(drawables.getDrawable(1)); + butNavRight.setContentDescription(getString(buttonTexts[1])); + break; + case POS_CHAPTERS: + butNavLeft.setScaleType(ScaleType.CENTER_CROP); + butNavLeft.post(new Runnable() { + + @Override + public void run() { + PicassoProvider.getMediaMetadataPicassoInstance(AudioplayerActivity.this) + .load(media.getImageUri()) + .fit() + .into(butNavLeft); + } + + }); + butNavLeft.setContentDescription(getString(buttonTexts[2])); + + butNavRight.setImageDrawable(drawables.getDrawable(0)); + butNavRight.setContentDescription(getString(buttonTexts[0])); + break; + } + } + } + + @Override + protected void setupGUI() { + super.setupGUI(); + resetFragmentView(); + drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); + navList = (ListView) findViewById(R.id.nav_list); + txtvTitle = (TextView) findViewById(R.id.txtvTitle); + butNavLeft = (ImageButton) findViewById(R.id.butNavLeft); + butNavRight = (ImageButton) findViewById(R.id.butNavRight); + butPlaybackSpeed = (Button) findViewById(R.id.butPlaybackSpeed); + + TypedArray typedArray = obtainStyledAttributes(new int[]{R.attr.nav_drawer_toggle}); + drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, typedArray.getResourceId(0, 0), R.string.drawer_open, R.string.drawer_close) { + String currentTitle = getSupportActionBar().getTitle().toString(); + + @Override + public void onDrawerOpened(View drawerView) { + super.onDrawerOpened(drawerView); + currentTitle = getSupportActionBar().getTitle().toString(); + getSupportActionBar().setTitle(R.string.app_name); + supportInvalidateOptionsMenu(); + } + + @Override + public void onDrawerClosed(View drawerView) { + super.onDrawerClosed(drawerView); + getSupportActionBar().setTitle(currentTitle); + supportInvalidateOptionsMenu(); + } + }; + typedArray.recycle(); + drawerToggle.setDrawerIndicatorEnabled(false); + drawerLayout.setDrawerListener(drawerToggle); + + navAdapter = new NavListAdapter(itemAccess, this); + navList.setAdapter(navAdapter); + navList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + int viewType = parent.getAdapter().getItemViewType(position); + if (viewType != NavListAdapter.VIEW_TYPE_SECTION_DIVIDER) { + int relPos = (viewType == NavListAdapter.VIEW_TYPE_NAV) ? position : position - NavListAdapter.SUBSCRIPTION_OFFSET; + Intent intent = new Intent(AudioplayerActivity.this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_NAV_TYPE, viewType); + intent.putExtra(MainActivity.EXTRA_NAV_INDEX, relPos); + startActivity(intent); + } + drawerLayout.closeDrawer(navList); + } + }); + drawerToggle.syncState(); + + butNavLeft.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (currentlyShownFragment == null + || currentlyShownPosition == POS_DESCR) { + switchToFragment(POS_COVER); + } else if (currentlyShownPosition == POS_COVER) { + switchToFragment(POS_DESCR); + } else if (currentlyShownPosition == POS_CHAPTERS) { + switchToFragment(POS_COVER); + } + } + }); + + butNavRight.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (currentlyShownPosition == POS_CHAPTERS) { + switchToFragment(POS_DESCR); + } else { + switchToFragment(POS_CHAPTERS); + } + } + }); + + butPlaybackSpeed.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (controller != null && controller.canSetPlaybackSpeed()) { + String[] availableSpeeds = UserPreferences + .getPlaybackSpeedArray(); + String currentSpeed = UserPreferences.getPlaybackSpeed(); + + // Provide initial value in case the speed list has changed + // out from under us + // and our current speed isn't in the new list + String newSpeed; + if (availableSpeeds.length > 0) { + newSpeed = availableSpeeds[0]; + } else { + newSpeed = "1.0"; + } + + for (int i = 0; i < availableSpeeds.length; i++) { + if (availableSpeeds[i].equals(currentSpeed)) { + if (i == availableSpeeds.length - 1) { + newSpeed = availableSpeeds[0]; + } else { + newSpeed = availableSpeeds[i + 1]; + } + break; + } + } + UserPreferences.setPlaybackSpeed(newSpeed); + controller.setPlaybackSpeed(Float.parseFloat(newSpeed)); + } + } + }); + + butPlaybackSpeed.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + VariableSpeedDialog.showDialog(AudioplayerActivity.this); + return true; + } + }); + } + + @Override + protected void onPlaybackSpeedChange() { + super.onPlaybackSpeedChange(); + updateButPlaybackSpeed(); + } + + private void updateButPlaybackSpeed() { + if (controller != null && controller.canSetPlaybackSpeed()) { + butPlaybackSpeed.setText(UserPreferences.getPlaybackSpeed()); + } + } + + @Override + protected void onPositionObserverUpdate() { + super.onPositionObserverUpdate(); + notifyMediaPositionChanged(); + } + + @Override + protected boolean loadMediaInfo() { + if (!super.loadMediaInfo()) { + return false; + } + final Playable media = controller.getMedia(); + if (media == null) { + return false; + } + txtvTitle.setText(media.getEpisodeTitle()); + if (media.getChapters() != null) { + butNavRight.setVisibility(View.VISIBLE); + } else { + butNavRight.setVisibility(View.INVISIBLE); + } + + + if (currentlyShownPosition == -1) { + if (!restoreFromPreferences()) { + switchToFragment(POS_COVER); + } + } + if (currentlyShownFragment instanceof AudioplayerContentFragment) { + ((AudioplayerContentFragment) currentlyShownFragment) + .onDataSetChanged(media); + } + + if (controller == null + || !controller.canSetPlaybackSpeed()) { + butPlaybackSpeed.setVisibility(View.GONE); + } else { + butPlaybackSpeed.setVisibility(View.VISIBLE); + } + + updateButPlaybackSpeed(); + return true; + } + + public void notifyMediaPositionChanged() { + if (chapterFragment != null) { + ArrayAdapter<SimpleChapter> adapter = (ArrayAdapter<SimpleChapter>) chapterFragment + .getListAdapter(); + adapter.notifyDataSetChanged(); + } + } + + @Override + protected void onReloadNotification(int notificationCode) { + if (notificationCode == PlaybackService.EXTRA_CODE_VIDEO) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "ReloadNotification received, switching to Videoplayer now"); + finish(); + startActivity(new Intent(this, VideoplayerActivity.class)); + + } + } + + @Override + protected void onBufferStart() { + postStatusMsg(R.string.player_buffering_msg); + } + + @Override + protected void onBufferEnd() { + clearStatusMsg(); + } + + @Override + public PlaybackController getPlaybackController() { + return controller; + } + + @Override + public boolean isDrawerOpen() { + return drawerLayout != null && navList != null && drawerLayout.isDrawerOpen(navList); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (!MenuItemUtils.isActivityDrawerOpen(this)) { + return super.onCreateOptionsMenu(menu); + } else { + return false; + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + if (!MenuItemUtils.isActivityDrawerOpen(this)) { + return super.onPrepareOptionsMenu(menu); + } else { + return false; + } + } + + public interface AudioplayerContentFragment { + public void onDataSetChanged(Playable media); + } + + @Override + protected int getContentViewResourceId() { + return R.layout.audioplayer_activity; + } + + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (drawerToggle != null && drawerToggle.onOptionsItemSelected(item)) { + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + private DBReader.NavDrawerData navDrawerData; + private AsyncTask<Void, Void, DBReader.NavDrawerData> loadTask; + + private void loadData() { + loadTask = new AsyncTask<Void, Void, DBReader.NavDrawerData>() { + @Override + protected DBReader.NavDrawerData doInBackground(Void... params) { + return DBReader.getNavDrawerData(AudioplayerActivity.this); + } + + @Override + protected void onPostExecute(DBReader.NavDrawerData result) { + super.onPostExecute(result); + navDrawerData = result; + if (navAdapter != null) { + navAdapter.notifyDataSetChanged(); + } + } + }; + loadTask.execute(); + } + + private void cancelLoadTask() { + if (loadTask != null) { + loadTask.cancel(true); + } + } + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((EventDistributor.FEED_LIST_UPDATE & arg) != 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received contentUpdate Intent."); + loadData(); + } + } + }; + + private final NavListAdapter.ItemAccess itemAccess = new NavListAdapter.ItemAccess() { + @Override + public int getCount() { + if (navDrawerData != null) { + return navDrawerData.feeds.size(); + } else { + return 0; + } + } + + @Override + public Feed getItem(int position) { + if (navDrawerData != null && position < navDrawerData.feeds.size()) { + return navDrawerData.feeds.get(position); + } else { + return null; + } + } + + @Override + public int getSelectedItemIndex() { + return -1; + } + + @Override + public int getQueueSize() { + return (navDrawerData != null) ? navDrawerData.queueSize : 0; + } + + @Override + public int getNumberOfUnreadItems() { + return (navDrawerData != null) ? navDrawerData.numUnreadItems : 0; + } + }; +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java new file mode 100644 index 000000000..a03fa7949 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java @@ -0,0 +1,248 @@ +package de.danoeh.antennapod.activity; + +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.NavUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.Spinner; +import android.widget.TextView; + +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.examples.HtmlToPlainText; +import org.jsoup.nodes.Document; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.adapter.FeedItemlistDescriptionAdapter; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; + +/** + * Default implementation of OnlineFeedViewActivity. Shows the downloaded feed's items with their descriptions, + * a subscribe button and a spinner for choosing alternate feed URLs. + */ +public class DefaultOnlineFeedViewActivity extends OnlineFeedViewActivity { + private static final String TAG = "DefaultOnlineFeedViewActivity"; + + private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED | EventDistributor.DOWNLOAD_QUEUED | EventDistributor.FEED_LIST_UPDATE; + private volatile List<Feed> feeds; + private Feed feed; + private String selectedDownloadUrl; + + private Button subscribeButton; + + @Override + protected void onCreate(Bundle arg0) { + super.onCreate(arg0); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + Intent destIntent = new Intent(this, MainActivity.class); + if (NavUtils.shouldUpRecreateTask(this, destIntent)) { + startActivity(destIntent); + } else { + NavUtils.navigateUpFromSameTask(this); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void loadData() { + super.loadData(); + feeds = DBReader.getFeedList(this); + } + + @Override + protected void beforeShowFeedInformation(Feed feed, Map<String, String> alternateFeedUrls) { + super.beforeShowFeedInformation(feed, alternateFeedUrls); + + // remove HTML tags from descriptions + + if (BuildConfig.DEBUG) Log.d(TAG, "Removing HTML from shownotes"); + if (feed.getItems() != null) { + HtmlToPlainText formatter = new HtmlToPlainText(); + for (FeedItem item : feed.getItems()) { + if (item.getDescription() != null) { + Document description = Jsoup.parse(item.getDescription()); + item.setDescription(StringUtils.trim(formatter.getPlainText(description))); + } + } + } + } + + @Override + protected void showFeedInformation(final Feed feed, final Map<String, String> alternateFeedUrls) { + super.showFeedInformation(feed, alternateFeedUrls); + setContentView(R.layout.listview_activity); + + this.feed = feed; + this.selectedDownloadUrl = feed.getDownload_url(); + EventDistributor.getInstance().register(listener); + ListView listView = (ListView) findViewById(R.id.listview); + LayoutInflater inflater = (LayoutInflater) + getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View header = inflater.inflate(R.layout.onlinefeedview_header, listView, false); + listView.addHeaderView(header); + + listView.setAdapter(new FeedItemlistDescriptionAdapter(this, 0, feed.getItems())); + + ImageView cover = (ImageView) header.findViewById(R.id.imgvCover); + TextView title = (TextView) header.findViewById(R.id.txtvTitle); + TextView author = (TextView) header.findViewById(R.id.txtvAuthor); + TextView description = (TextView) header.findViewById(R.id.txtvDescription); + Spinner spAlternateUrls = (Spinner) header.findViewById(R.id.spinnerAlternateUrls); + + subscribeButton = (Button) header.findViewById(R.id.butSubscribe); + + if (feed.getImage() != null) { + PicassoProvider.getDefaultPicassoInstance(this) + .load(feed.getImage().getDownload_url()) + .fit() + .into(cover); + } + + title.setText(feed.getTitle()); + author.setText(feed.getAuthor()); + description.setText(feed.getDescription()); + + subscribeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + try { + Feed f = new Feed(selectedDownloadUrl, new Date(), feed.getTitle()); + f.setPreferences(feed.getPreferences()); + DefaultOnlineFeedViewActivity.this.feed = f; + + DownloadRequester.getInstance().downloadFeed( + DefaultOnlineFeedViewActivity.this, + f); + } catch (DownloadRequestException e) { + e.printStackTrace(); + DownloadRequestErrorDialogCreator.newRequestErrorDialog(DefaultOnlineFeedViewActivity.this, + e.getMessage()); + } + setSubscribeButtonState(feed); + } + }); + + if (alternateFeedUrls.isEmpty()) { + spAlternateUrls.setVisibility(View.GONE); + } else { + spAlternateUrls.setVisibility(View.VISIBLE); + + final List<String> alternateUrlsList = new ArrayList<String>(); + final List<String> alternateUrlsTitleList = new ArrayList<String>(); + + alternateUrlsList.add(feed.getDownload_url()); + alternateUrlsTitleList.add(feed.getTitle()); + + + alternateUrlsList.addAll(alternateFeedUrls.keySet()); + for (String url : alternateFeedUrls.keySet()) { + alternateUrlsTitleList.add(alternateFeedUrls.get(url)); + } + ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, alternateUrlsTitleList); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spAlternateUrls.setAdapter(adapter); + spAlternateUrls.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + selectedDownloadUrl = alternateUrlsList.get(position); + } + + @Override + public void onNothingSelected(AdapterView<?> parent) { + + } + }); + + + } + setSubscribeButtonState(feed); + + } + + private boolean feedInFeedlist(Feed feed) { + if (feeds == null || feed == null) + return false; + for (Feed f : feeds) { + if (f.getIdentifyingValue().equals(feed.getIdentifyingValue())) { + return true; + } + } + return false; + } + + private void setSubscribeButtonState(Feed feed) { + if (subscribeButton != null && feed != null) { + if (DownloadRequester.getInstance().isDownloadingFile(feed.getDownload_url())) { + subscribeButton.setEnabled(false); + subscribeButton.setText(R.string.downloading_label); + } else if (feedInFeedlist(feed)) { + subscribeButton.setEnabled(false); + subscribeButton.setText(R.string.subscribed_label); + } else { + subscribeButton.setEnabled(true); + subscribeButton.setText(R.string.subscribe_label); + } + } + } + + EventDistributor.EventListener listener = new EventDistributor.EventListener() { + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & EventDistributor.FEED_LIST_UPDATE) != 0) { + new AsyncTask<Void, Void, List<Feed>>() { + @Override + protected List<Feed> doInBackground(Void... params) { + return DBReader.getFeedList(DefaultOnlineFeedViewActivity.this); + } + + @Override + protected void onPostExecute(List<Feed> feeds) { + super.onPostExecute(feeds); + DefaultOnlineFeedViewActivity.this.feeds = feeds; + setSubscribeButtonState(feed); + } + }.execute(); + } else if ((arg & EVENTS) != 0) { + setSubscribeButtonState(feed); + } + } + }; + + @Override + protected void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(listener); + } +} + diff --git a/app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java new file mode 100644 index 000000000..06a11c775 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java @@ -0,0 +1,370 @@ +package de.danoeh.antennapod.activity; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.os.Environment; +import android.os.FileObserver; +import android.support.v4.app.NavUtils; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.*; +import android.widget.AdapterView.OnItemClickListener; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +/** + * Let's the user choose a directory on the storage device. The selected folder + * will be sent back to the starting activity as an activity result. + */ +public class DirectoryChooserActivity extends ActionBarActivity { + private static final String TAG = "DirectoryChooserActivity"; + + private static final String CREATE_DIRECTORY_NAME = "AntennaPod"; + + public static final String RESULT_SELECTED_DIR = "selected_dir"; + public static final int RESULT_CODE_DIR_SELECTED = 1; + + private Button butConfirm; + private Button butCancel; + private ImageButton butNavUp; + private TextView txtvSelectedFolder; + private ListView listDirectories; + + private ArrayAdapter<String> listDirectoriesAdapter; + private ArrayList<String> filenames; + /** The directory that is currently being shown. */ + private File selectedDir; + private File[] filesInDir; + + private FileObserver fileObserver; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setContentView(R.layout.directory_chooser); + butConfirm = (Button) findViewById(R.id.butConfirm); + butCancel = (Button) findViewById(R.id.butCancel); + butNavUp = (ImageButton) findViewById(R.id.butNavUp); + txtvSelectedFolder = (TextView) findViewById(R.id.txtvSelectedFolder); + listDirectories = (ListView) findViewById(R.id.directory_list); + + butConfirm.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (isValidFile(selectedDir)) { + if (selectedDir.list().length == 0) { + returnSelectedFolder(); + } else { + showNonEmptyDirectoryWarning(); + } + } + } + + private void showNonEmptyDirectoryWarning() { + AlertDialog.Builder adb = new AlertDialog.Builder( + DirectoryChooserActivity.this); + adb.setTitle(R.string.folder_not_empty_dialog_title); + adb.setMessage(R.string.folder_not_empty_dialog_msg); + adb.setNegativeButton(R.string.cancel_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + dialog.dismiss(); + } + }); + adb.setPositiveButton(R.string.confirm_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + dialog.dismiss(); + returnSelectedFolder(); + } + }); + adb.create().show(); + } + }); + + butCancel.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + setResult(Activity.RESULT_CANCELED); + finish(); + } + }); + + listDirectories.setOnItemClickListener(new OnItemClickListener() { + + @Override + public void onItemClick(AdapterView<?> adapter, View view, + int position, long id) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Selected index: " + position); + if (filesInDir != null && position >= 0 + && position < filesInDir.length) { + changeDirectory(filesInDir[position]); + } + } + }); + + butNavUp.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + File parent = null; + if (selectedDir != null + && (parent = selectedDir.getParentFile()) != null) { + changeDirectory(parent); + } + } + }); + + filenames = new ArrayList<String>(); + listDirectoriesAdapter = new ArrayAdapter<String>(this, + android.R.layout.simple_list_item_1, filenames); + listDirectories.setAdapter(listDirectoriesAdapter); + changeDirectory(Environment.getExternalStorageDirectory()); + } + + /** + * Finishes the activity and returns the selected folder as a result. The + * selected folder can also be null. + */ + private void returnSelectedFolder() { + if (selectedDir != null && BuildConfig.DEBUG) + Log.d(TAG, "Returning " + selectedDir.getAbsolutePath() + + " as result"); + Intent resultData = new Intent(); + if (selectedDir != null) { + resultData.putExtra(RESULT_SELECTED_DIR, + selectedDir.getAbsolutePath()); + } + setResult(RESULT_CODE_DIR_SELECTED, resultData); + finish(); + } + + @Override + protected void onPause() { + super.onPause(); + if (fileObserver != null) { + fileObserver.stopWatching(); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (fileObserver != null) { + fileObserver.startWatching(); + } + } + + /** + * Change the directory that is currently being displayed. + * + * @param dir + * The file the activity should switch to. This File must be + * non-null and a directory, otherwise the displayed directory + * will not be changed + */ + private void changeDirectory(File dir) { + if (dir != null && dir.isDirectory()) { + File[] contents = dir.listFiles(); + if (contents != null) { + int numDirectories = 0; + for (File f : contents) { + if (f.isDirectory()) { + numDirectories++; + } + } + filesInDir = new File[numDirectories]; + filenames.clear(); + for (int i = 0, counter = 0; i < numDirectories; counter++) { + if (contents[counter].isDirectory()) { + filesInDir[i] = contents[counter]; + filenames.add(contents[counter].getName()); + i++; + } + } + Arrays.sort(filesInDir); + Collections.sort(filenames); + selectedDir = dir; + txtvSelectedFolder.setText(dir.getAbsolutePath()); + listDirectoriesAdapter.notifyDataSetChanged(); + fileObserver = createFileObserver(dir.getAbsolutePath()); + fileObserver.startWatching(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Changed directory to " + dir.getAbsolutePath()); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Could not change folder: contents of dir were null"); + } + } else { + if (dir == null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Could not change folder: dir was null"); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Could not change folder: dir is no directory"); + } + } + refreshButtonState(); + } + + /** + * Changes the state of the buttons depending on the currently selected file + * or folder. + */ + private void refreshButtonState() { + if (selectedDir != null) { + butConfirm.setEnabled(isValidFile(selectedDir)); + supportInvalidateOptionsMenu(); + } + } + + /** Refresh the contents of the directory that is currently shown. */ + private void refreshDirectory() { + if (selectedDir != null) { + changeDirectory(selectedDir); + } + } + + /** Sets up a FileObserver to watch the current directory. */ + private FileObserver createFileObserver(String path) { + return new FileObserver(path, FileObserver.CREATE | FileObserver.DELETE + | FileObserver.MOVED_FROM | FileObserver.MOVED_TO) { + + @Override + public void onEvent(int event, String path) { + if (BuildConfig.DEBUG) + Log.d(TAG, "FileObserver received event " + event); + runOnUiThread(new Runnable() { + + @Override + public void run() { + refreshDirectory(); + } + }); + } + }; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.new_folder_item) + .setVisible(isValidFile(selectedDir)); + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.directory_chooser, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + case R.id.new_folder_item: + openNewFolderDialog(); + return true; + case R.id.set_to_default_folder_item: + selectedDir = null; + returnSelectedFolder(); + return true; + default: + return false; + } + } + + /** + * Shows a confirmation dialog that asks the user if he wants to create a + * new folder. + */ + private void openNewFolderDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.create_folder_label); + builder.setMessage(String.format(getString(R.string.create_folder_msg), + CREATE_DIRECTORY_NAME)); + builder.setNegativeButton(R.string.cancel_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + builder.setPositiveButton(R.string.confirm_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + int msg = createFolder(); + Toast t = Toast.makeText(DirectoryChooserActivity.this, + msg, Toast.LENGTH_SHORT); + t.show(); + } + }); + builder.create().show(); + } + + /** + * Creates a new folder in the current directory with the name + * CREATE_DIRECTORY_NAME. + */ + private int createFolder() { + if (selectedDir == null) { + return R.string.create_folder_error; + } else if (selectedDir.canWrite()) { + File newDir = new File(selectedDir, CREATE_DIRECTORY_NAME); + if (!newDir.exists()) { + boolean result = newDir.mkdir(); + if (result) { + return R.string.create_folder_success; + } else { + return R.string.create_folder_error; + } + } else { + return R.string.create_folder_error_already_exists; + } + } else { + return R.string.create_folder_error_no_write_access; + } + } + + /** Returns true if the selected file or directory would be valid selection. */ + private boolean isValidFile(File file) { + return (file != null && file.isDirectory() && file.canRead() && file + .canWrite()); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java new file mode 100644 index 000000000..c5f25d813 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java @@ -0,0 +1,110 @@ +package de.danoeh.antennapod.activity; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.download.DownloadRequest; +import de.danoeh.antennapod.storage.DownloadRequester; + +/** + * Shows a username and a password text field. + * The activity MUST be started with the ARG_DOWNlOAD_REQUEST argument set to a non-null value. + * Other arguments are optional. + * The activity's result will be the same DownloadRequest with the entered username and password. + */ +public class DownloadAuthenticationActivity extends ActionBarActivity { + private static final String TAG = "DownloadAuthenticationActivity"; + + /** + * The download request object that contains information about the resource that requires a username and a password + */ + public static final String ARG_DOWNLOAD_REQUEST = "request"; + /** + * True if the request should be sent to the DownloadRequester when this activity is finished, false otherwise. + * The default value is false. + */ + public static final String ARG_SEND_TO_DOWNLOAD_REQUESTER_BOOL = "send_to_downloadrequester"; + + public static final String RESULT_REQUEST = "request"; + + private EditText etxtUsername; + private EditText etxtPassword; + private Button butConfirm; + private Button butCancel; + private TextView txtvDescription; + + private DownloadRequest request; + private boolean sendToDownloadRequester; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + getSupportActionBar().hide(); + setContentView(R.layout.download_authentication_activity); + + etxtUsername = (EditText) findViewById(R.id.etxtUsername); + etxtPassword = (EditText) findViewById(R.id.etxtPassword); + butConfirm = (Button) findViewById(R.id.butConfirm); + butCancel = (Button) findViewById(R.id.butCancel); + txtvDescription = (TextView) findViewById(R.id.txtvDescription); + + Validate.isTrue(getIntent().hasExtra(ARG_DOWNLOAD_REQUEST), "Download request missing"); + + request = getIntent().getParcelableExtra(ARG_DOWNLOAD_REQUEST); + sendToDownloadRequester = getIntent().getBooleanExtra(ARG_SEND_TO_DOWNLOAD_REQUESTER_BOOL, false); + + if (savedInstanceState != null) { + etxtUsername.setText(savedInstanceState.getString("username")); + etxtPassword.setText(savedInstanceState.getString("password")); + } + + txtvDescription.setText(txtvDescription.getText() + ":\n\n" + request.getTitle()); + + butCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + setResult(Activity.RESULT_CANCELED); + finish(); + } + }); + + butConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String username = etxtUsername.getText().toString(); + String password = etxtPassword.getText().toString(); + request.setUsername(username); + request.setPassword(password); + Intent result = new Intent(); + result.putExtra(RESULT_REQUEST, request); + setResult(Activity.RESULT_OK, result); + + if (sendToDownloadRequester) { + if (BuildConfig.DEBUG) Log.d(TAG, "Sending request to DownloadRequester"); + DownloadRequester.getInstance().download(DownloadAuthenticationActivity.this, request); + } + finish(); + } + }); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString("username", etxtUsername.getText().toString()); + outState.putString("password", etxtPassword.getText().toString()); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java new file mode 100644 index 000000000..5cf187eb6 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java @@ -0,0 +1,192 @@ +package de.danoeh.antennapod.activity; + +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.*; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.util.LangUtils; +import de.danoeh.antennapod.util.menuhandler.FeedMenuHandler; + +/** + * Displays information about a feed. + */ +public class FeedInfoActivity extends ActionBarActivity { + private static final String TAG = "FeedInfoActivity"; + + public static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId"; + + private Feed feed; + + private ImageView imgvCover; + private TextView txtvTitle; + private TextView txtvDescription; + private TextView txtvLanguage; + private TextView txtvAuthor; + private EditText etxtUsername; + private EditText etxtPassword; + private CheckBox cbxAutoDownload; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + setContentView(R.layout.feedinfo); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + long feedId = getIntent().getLongExtra(EXTRA_FEED_ID, -1); + + imgvCover = (ImageView) findViewById(R.id.imgvCover); + txtvTitle = (TextView) findViewById(R.id.txtvTitle); + txtvDescription = (TextView) findViewById(R.id.txtvDescription); + txtvLanguage = (TextView) findViewById(R.id.txtvLanguage); + txtvAuthor = (TextView) findViewById(R.id.txtvAuthor); + cbxAutoDownload = (CheckBox) findViewById(R.id.cbxAutoDownload); + etxtUsername = (EditText) findViewById(R.id.etxtUsername); + etxtPassword = (EditText) findViewById(R.id.etxtPassword); + + AsyncTask<Long, Void, Feed> loadTask = new AsyncTask<Long, Void, Feed>() { + + @Override + protected Feed doInBackground(Long... params) { + return DBReader.getFeed(FeedInfoActivity.this, params[0]); + } + + @Override + protected void onPostExecute(Feed result) { + if (result != null) { + feed = result; + if (BuildConfig.DEBUG) + Log.d(TAG, "Language is " + feed.getLanguage()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Author is " + feed.getAuthor()); + imgvCover.post(new Runnable() { + + @Override + public void run() { + PicassoProvider.getDefaultPicassoInstance(FeedInfoActivity.this) + .load(feed.getImageUri()) + .fit() + .into(imgvCover); + } + }); + + txtvTitle.setText(feed.getTitle()); + txtvDescription.setText(feed.getDescription()); + if (feed.getAuthor() != null) { + txtvAuthor.setText(feed.getAuthor()); + } + if (feed.getLanguage() != null) { + txtvLanguage.setText(LangUtils + .getLanguageString(feed.getLanguage())); + } + + cbxAutoDownload.setEnabled(UserPreferences.isEnableAutodownload()); + cbxAutoDownload.setChecked(feed.getPreferences().getAutoDownload()); + cbxAutoDownload.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { + feed.getPreferences().setAutoDownload(checked); + feed.savePreferences(FeedInfoActivity.this); + } + }); + + etxtUsername.setText(feed.getPreferences().getUsername()); + etxtPassword.setText(feed.getPreferences().getPassword()); + + etxtUsername.addTextChangedListener(authTextWatcher); + etxtPassword.addTextChangedListener(authTextWatcher); + + supportInvalidateOptionsMenu(); + + } else { + Log.e(TAG, "Activity was started with invalid arguments"); + } + } + }; + loadTask.execute(feedId); + } + + + private boolean authInfoChanged = false; + + private TextWatcher authTextWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + authInfoChanged = true; + } + }; + + @Override + protected void onPause() { + super.onPause(); + if (feed != null && authInfoChanged) { + Log.d(TAG, "Auth info changed, saving credentials"); + FeedPreferences prefs = feed.getPreferences(); + prefs.setUsername(etxtUsername.getText().toString()); + prefs.setPassword(etxtPassword.getText().toString()); + DBWriter.setFeedPreferences(this, prefs); + authInfoChanged = false; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.feedinfo, menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.support_item).setVisible( + feed != null && feed.getPaymentLink() != null); + menu.findItem(R.id.share_link_item).setVisible(feed != null &&feed.getLink() != null); + menu.findItem(R.id.visit_website_item).setVisible(feed != null && feed.getLink() != null); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + default: + try { + return FeedMenuHandler.onOptionsItemClicked(this, item, feed); + } catch (DownloadRequestException e) { + e.printStackTrace(); + DownloadRequestErrorDialogCreator.newRequestErrorDialog(this, + e.getMessage()); + } + return super.onOptionsItemSelected(item); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java new file mode 100644 index 000000000..8dde14d3b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java @@ -0,0 +1,125 @@ +package de.danoeh.antennapod.activity; + + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.TextView; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.util.flattr.FlattrUtils; +import org.shredzone.flattr4j.exception.FlattrException; + +/** Guides the user through the authentication process */ + +public class FlattrAuthActivity extends ActionBarActivity { + private static final String TAG = "FlattrAuthActivity"; + + private TextView txtvExplanation; + private Button butAuthenticate; + private Button butReturn; + + private boolean authSuccessful; + + private static FlattrAuthActivity singleton; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + singleton = this; + authSuccessful = false; + if (BuildConfig.DEBUG) Log.d(TAG, "Activity created"); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + setContentView(R.layout.flattr_auth); + txtvExplanation = (TextView) findViewById(R.id.txtvExplanation); + butAuthenticate = (Button) findViewById(R.id.but_authenticate); + butReturn = (Button) findViewById(R.id.but_return_home); + + butReturn.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(FlattrAuthActivity.this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } + }); + + butAuthenticate.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + try { + FlattrUtils.startAuthProcess(FlattrAuthActivity.this); + } catch (FlattrException e) { + e.printStackTrace(); + } + } + }); + } + + public static FlattrAuthActivity getInstance() { + return singleton; + } + + @Override + protected void onResume() { + super.onResume(); + if (BuildConfig.DEBUG) Log.d(TAG, "Activity resumed"); + Uri uri = getIntent().getData(); + if (uri != null) { + if (BuildConfig.DEBUG) Log.d(TAG, "Received uri"); + FlattrUtils.handleCallback(this, uri); + } + } + + public void handleAuthenticationSuccess() { + authSuccessful = true; + txtvExplanation.setText(R.string.flattr_auth_success); + butAuthenticate.setEnabled(false); + butReturn.setVisibility(View.VISIBLE); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + return true; + } + + + + @Override + protected void onPause() { + super.onPause(); + if (authSuccessful) { + finish(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + if (authSuccessful) { + Intent intent = new Intent(this, PreferenceActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } else { + finish(); + } + break; + default: + return false; + } + return true; + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java new file mode 100644 index 000000000..b7014dab2 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java @@ -0,0 +1,432 @@ +package de.danoeh.antennapod.activity; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.media.AudioManager; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.ActionBarDrawerToggle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.widget.DrawerLayout; +import android.support.v7.app.ActionBar; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.*; +import android.widget.AdapterView; +import android.widget.ListView; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.adapter.NavListAdapter; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.fragment.*; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.StorageUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +import java.util.List; + +/** + * The activity that is shown when the user launches the app. + */ +public class MainActivity extends ActionBarActivity implements NavDrawerActivity{ + private static final String TAG = "MainActivity"; + private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED + | EventDistributor.DOWNLOAD_QUEUED + | EventDistributor.FEED_LIST_UPDATE + | EventDistributor.UNREAD_ITEMS_UPDATE + | EventDistributor.QUEUE_UPDATE; + + public static final String PREF_NAME = "MainActivityPrefs"; + public static final String PREF_IS_FIRST_LAUNCH = "prefMainActivityIsFirstLaunch"; + + public static final String EXTRA_NAV_INDEX = "nav_index"; + public static final String EXTRA_NAV_TYPE = "nav_type"; + public static final String EXTRA_FRAGMENT_ARGS = "fragment_args"; + + public static final int POS_NEW = 0, + POS_QUEUE = 1, + POS_DOWNLOADS = 2, + POS_HISTORY = 3, + POS_ADD = 4; + + private ExternalPlayerFragment externalPlayerFragment; + private DrawerLayout drawerLayout; + + private ListView navList; + private NavListAdapter navAdapter; + + private ActionBarDrawerToggle drawerToggle; + + private CharSequence drawerTitle; + private CharSequence currentTitle; + + + @Override + public void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); + StorageUtils.checkStorageAvailability(this); + setContentView(R.layout.main); + setVolumeControlStream(AudioManager.STREAM_MUSIC); + + drawerTitle = currentTitle = getTitle(); + + drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); + navList = (ListView) findViewById(R.id.nav_list); + + TypedArray typedArray = obtainStyledAttributes(new int[]{R.attr.nav_drawer_toggle}); + drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, typedArray.getResourceId(0, 0), R.string.drawer_open, R.string.drawer_close) { + @Override + public void onDrawerOpened(View drawerView) { + super.onDrawerOpened(drawerView); + currentTitle = getSupportActionBar().getTitle(); + getSupportActionBar().setTitle(drawerTitle); + supportInvalidateOptionsMenu(); + } + + @Override + public void onDrawerClosed(View drawerView) { + super.onDrawerClosed(drawerView); + getSupportActionBar().setTitle(currentTitle); + supportInvalidateOptionsMenu(); + + } + }; + typedArray.recycle(); + + drawerLayout.setDrawerListener(drawerToggle); + FragmentManager fm = getSupportFragmentManager(); + + FragmentTransaction transaction = fm.beginTransaction(); + + Fragment mainFragment = fm.findFragmentByTag("main"); + if (mainFragment != null) { + transaction.replace(R.id.main_view, mainFragment); + } else { + loadFragment(NavListAdapter.VIEW_TYPE_NAV, POS_NEW, null); + } + + externalPlayerFragment = new ExternalPlayerFragment(); + transaction.replace(R.id.playerFragment, externalPlayerFragment); + transaction.commit(); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setHomeButtonEnabled(true); + + navAdapter = new NavListAdapter(itemAccess, this); + navList.setAdapter(navAdapter); + navList.setOnItemClickListener(navListClickListener); + + checkFirstLaunch(); + } + + private void checkFirstLaunch() { + SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE); + if (prefs.getBoolean(PREF_IS_FIRST_LAUNCH, true)) { + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + drawerLayout.openDrawer(navList); + } + }, 1500); + + SharedPreferences.Editor edit = prefs.edit(); + edit.putBoolean(PREF_IS_FIRST_LAUNCH, false); + edit.commit(); + } + } + + public ActionBar getMainActivtyActionBar() { + return getSupportActionBar(); + } + + public boolean isDrawerOpen() { + return drawerLayout != null && navList != null && drawerLayout.isDrawerOpen(navList); + } + + public List<Feed> getFeeds() { + return (navDrawerData != null) ? navDrawerData.feeds : null; + } + + private void loadFragment(int viewType, int relPos, Bundle args) { + FragmentManager fragmentManager = getSupportFragmentManager(); + // clear back stack + for (int i = 0; i < fragmentManager.getBackStackEntryCount(); i++) { + fragmentManager.popBackStack(); + } + + FragmentTransaction fT = fragmentManager.beginTransaction(); + Fragment fragment = null; + if (viewType == NavListAdapter.VIEW_TYPE_NAV) { + switch (relPos) { + case POS_NEW: + fragment = new NewEpisodesFragment(); + break; + case POS_QUEUE: + fragment = new QueueFragment(); + break; + case POS_DOWNLOADS: + fragment = new DownloadsFragment(); + break; + case POS_HISTORY: + fragment = new PlaybackHistoryFragment(); + break; + case POS_ADD: + fragment = new AddFeedFragment(); + break; + + } + currentTitle = getString(NavListAdapter.NAV_TITLES[relPos]); + selectedNavListIndex = relPos; + + } else if (viewType == NavListAdapter.VIEW_TYPE_SUBSCRIPTION) { + Feed feed = itemAccess.getItem(relPos); + currentTitle = ""; + fragment = ItemlistFragment.newInstance(feed.getId()); + selectedNavListIndex = NavListAdapter.SUBSCRIPTION_OFFSET + relPos; + + } + if (fragment != null) { + if (args != null) { + fragment.setArguments(args); + } + fT.replace(R.id.main_view, fragment, "main"); + fragmentManager.popBackStack(); + } + fT.commit(); + getSupportActionBar().setTitle(currentTitle); + if (navAdapter != null) { + navAdapter.notifyDataSetChanged(); + } + } + + public void loadNavFragment(int position, Bundle args) { + loadFragment(NavListAdapter.VIEW_TYPE_NAV, position, args); + } + + public void loadFeedFragment(long feedID) { + if (navDrawerData != null) { + for (int i = 0; i < navDrawerData.feeds.size(); i++) { + if (navDrawerData.feeds.get(i).getId() == feedID) { + loadFragment(NavListAdapter.VIEW_TYPE_SUBSCRIPTION, i, null); + break; + } + } + } + } + + public void loadChildFragment(Fragment fragment) { + Validate.notNull(fragment); + FragmentManager fm = getSupportFragmentManager(); + fm.beginTransaction() + .replace(R.id.main_view, fragment, "main") + .addToBackStack(null) + .commit(); + } + + private AdapterView.OnItemClickListener navListClickListener = new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + int viewType = parent.getAdapter().getItemViewType(position); + if (viewType != NavListAdapter.VIEW_TYPE_SECTION_DIVIDER && position != selectedNavListIndex) { + int relPos = (viewType == NavListAdapter.VIEW_TYPE_NAV) ? position : position - NavListAdapter.SUBSCRIPTION_OFFSET; + loadFragment(viewType, relPos, null); + selectedNavListIndex = position; + navAdapter.notifyDataSetChanged(); + } + drawerLayout.closeDrawer(navList); + } + }; + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + drawerToggle.syncState(); + if (savedInstanceState != null) { + currentTitle = savedInstanceState.getString("title"); + if (!drawerLayout.isDrawerOpen(navList)) { + getSupportActionBar().setTitle(currentTitle); + } + selectedNavListIndex = savedInstanceState.getInt("selectedNavIndex"); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + drawerToggle.onConfigurationChanged(newConfig); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString("title", getSupportActionBar().getTitle().toString()); + outState.putInt("selectedNavIndex", selectedNavListIndex); + + } + + @Override + protected void onPause() { + super.onPause(); + } + + @Override + protected void onResume() { + super.onResume(); + StorageUtils.checkStorageAvailability(this); + EventDistributor.getInstance().register(contentUpdate); + + Intent intent = getIntent(); + if (navDrawerData != null && intent.hasExtra(EXTRA_NAV_INDEX) && intent.hasExtra(EXTRA_NAV_TYPE)) { + handleNavIntent(); + } + + loadData(); + } + + @Override + protected void onStop() { + super.onStop(); + cancelLoadTask(); + EventDistributor.getInstance().unregister(contentUpdate); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (drawerToggle.onOptionsItemSelected(item)) { + return true; + } + switch (item.getItemId()) { + case R.id.show_preferences: + startActivity(new Intent(this, PreferenceActivity.class)); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.main, menu); + return true; + } + + private DBReader.NavDrawerData navDrawerData; + private AsyncTask<Void, Void, DBReader.NavDrawerData> loadTask; + private int selectedNavListIndex = 0; + + private NavListAdapter.ItemAccess itemAccess = new NavListAdapter.ItemAccess() { + @Override + public int getCount() { + if (navDrawerData != null) { + return navDrawerData.feeds.size(); + } else { + return 0; + } + } + + @Override + public Feed getItem(int position) { + if (navDrawerData != null && position < navDrawerData.feeds.size()) { + return navDrawerData.feeds.get(position); + } else { + return null; + } + } + + @Override + public int getSelectedItemIndex() { + return selectedNavListIndex; + } + + @Override + public int getQueueSize() { + return (navDrawerData != null) ? navDrawerData.queueSize : 0; + } + + @Override + public int getNumberOfUnreadItems() { + return (navDrawerData != null) ? navDrawerData.numUnreadItems : 0; + } + + + }; + + private void loadData() { + cancelLoadTask(); + loadTask = new AsyncTask<Void, Void, DBReader.NavDrawerData>() { + @Override + protected DBReader.NavDrawerData doInBackground(Void... params) { + return DBReader.getNavDrawerData(MainActivity.this); + } + + @Override + protected void onPostExecute(DBReader.NavDrawerData result) { + super.onPostExecute(navDrawerData); + boolean handleIntent = (navDrawerData == null); + + navDrawerData = result; + navAdapter.notifyDataSetChanged(); + + if (handleIntent) { + handleNavIntent(); + } + } + }; + loadTask.execute(); + } + + private void cancelLoadTask() { + if (loadTask != null) { + loadTask.cancel(true); + } + } + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((EVENTS & arg) != 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received contentUpdate Intent."); + loadData(); + } + } + }; + + private void handleNavIntent() { + Intent intent = getIntent(); + if (intent.hasExtra(EXTRA_NAV_INDEX) && intent.hasExtra(EXTRA_NAV_TYPE)) { + int index = intent.getIntExtra(EXTRA_NAV_INDEX, 0); + int type = intent.getIntExtra(EXTRA_NAV_TYPE, NavListAdapter.VIEW_TYPE_NAV); + Bundle args = intent.getBundleExtra(EXTRA_FRAGMENT_ARGS); + loadFragment(type, index, args); + } + setIntent(new Intent(MainActivity.this, MainActivity.class)); // to avoid handling the intent twice when the configuration changes + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java new file mode 100644 index 000000000..2e5372b60 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java @@ -0,0 +1,525 @@ +package de.danoeh.antennapod.activity; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.PixelFormat; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.Window; +import android.widget.ImageButton; +import android.widget.SeekBar; +import android.widget.SeekBar.OnSeekBarChangeListener; +import android.widget.TextView; +import com.doomonafireball.betterpickers.hmspicker.HmsPickerBuilder; +import com.doomonafireball.betterpickers.hmspicker.HmsPickerDialogFragment; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.dialog.TimeDialog; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.ShareUtils; +import de.danoeh.antennapod.util.StorageUtils; +import de.danoeh.antennapod.util.playback.MediaPlayerError; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.util.playback.PlaybackController; +import org.shredzone.flattr4j.model.User; + +/** + * Provides general features which are both needed for playing audio and video + * files. + */ +public abstract class MediaplayerActivity extends ActionBarActivity + implements OnSeekBarChangeListener { + private static final String TAG = "MediaplayerActivity"; + + protected PlaybackController controller; + + protected TextView txtvPosition; + protected TextView txtvLength; + protected SeekBar sbPosition; + protected ImageButton butPlay; + protected ImageButton butRev; + protected ImageButton butFF; + + private PlaybackController newPlaybackController() { + return new PlaybackController(this, false) { + + @Override + public void setupGUI() { + MediaplayerActivity.this.setupGUI(); + } + + @Override + public void onPositionObserverUpdate() { + MediaplayerActivity.this.onPositionObserverUpdate(); + } + + @Override + public void onBufferStart() { + MediaplayerActivity.this.onBufferStart(); + } + + @Override + public void onBufferEnd() { + MediaplayerActivity.this.onBufferEnd(); + } + + @Override + public void onBufferUpdate(float progress) { + MediaplayerActivity.this.onBufferUpdate(progress); + } + + @Override + public void handleError(int code) { + MediaplayerActivity.this.handleError(code); + } + + @Override + public void onReloadNotification(int code) { + MediaplayerActivity.this.onReloadNotification(code); + } + + @Override + public void onSleepTimerUpdate() { + supportInvalidateOptionsMenu(); + } + + @Override + public ImageButton getPlayButton() { + return butPlay; + } + + @Override + public void postStatusMsg(int msg) { + MediaplayerActivity.this.postStatusMsg(msg); + } + + @Override + public void clearStatusMsg() { + MediaplayerActivity.this.clearStatusMsg(); + } + + @Override + public boolean loadMediaInfo() { + return MediaplayerActivity.this.loadMediaInfo(); + } + + @Override + public void onAwaitingVideoSurface() { + MediaplayerActivity.this.onAwaitingVideoSurface(); + } + + @Override + public void onServiceQueried() { + MediaplayerActivity.this.onServiceQueried(); + } + + @Override + public void onShutdownNotification() { + finish(); + } + + @Override + public void onPlaybackEnd() { + finish(); + } + + @Override + public void onPlaybackSpeedChange() { + MediaplayerActivity.this.onPlaybackSpeedChange(); + } + + @Override + protected void setScreenOn(boolean enable) { + super.setScreenOn(enable); + MediaplayerActivity.this.setScreenOn(enable); + } + }; + + } + + protected void onPlaybackSpeedChange() { + + } + + protected void onServiceQueried() { + supportInvalidateOptionsMenu(); + } + + protected void chooseTheme() { + setTheme(UserPreferences.getTheme()); + } + + protected void setScreenOn(boolean enable) { + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + chooseTheme(); + super.onCreate(savedInstanceState); + + // subclasses might use this feature + supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); + + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating Activity"); + StorageUtils.checkStorageAvailability(this); + setVolumeControlStream(AudioManager.STREAM_MUSIC); + + orientation = getResources().getConfiguration().orientation; + getWindow().setFormat(PixelFormat.TRANSPARENT); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + protected void onPause() { + super.onPause(); + controller.reinitServiceIfPaused(); + controller.pause(); + } + + /** + * Should be used to switch to another player activity if the mime type is + * not the correct one for the current activity. + */ + protected abstract void onReloadNotification(int notificationCode); + + /** + * Should be used to inform the user that the PlaybackService is currently + * buffering. + */ + protected abstract void onBufferStart(); + + /** + * Should be used to hide the view that was showing the 'buffering'-message. + */ + protected abstract void onBufferEnd(); + + protected void onBufferUpdate(float progress) { + if (sbPosition != null) { + sbPosition.setSecondaryProgress((int) progress + * sbPosition.getMax()); + } + } + + /** + * Current screen orientation. + */ + protected int orientation; + + @Override + protected void onStart() { + super.onStart(); + if (controller != null) { + controller.release(); + } + controller = newPlaybackController(); + } + + @Override + protected void onStop() { + super.onStop(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Activity stopped"); + if (controller != null) { + controller.release(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Activity destroyed"); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.mediaplayer, menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + Playable media = controller.getMedia(); + + menu.findItem(R.id.support_item).setVisible( + media != null && media.getPaymentLink() != null && + (media instanceof FeedMedia) && + ((FeedMedia) media).getItem().getFlattrStatus().flattrable() + ); + menu.findItem(R.id.share_link_item).setVisible( + media != null && media.getWebsiteLink() != null); + menu.findItem(R.id.visit_website_item).setVisible( + media != null && media.getWebsiteLink() != null); + menu.findItem(R.id.skip_episode_item).setVisible(media != null); + boolean sleepTimerSet = controller.sleepTimerActive(); + boolean sleepTimerNotSet = controller.sleepTimerNotActive(); + menu.findItem(R.id.set_sleeptimer_item).setVisible(sleepTimerNotSet); + menu.findItem(R.id.disable_sleeptimer_item).setVisible(sleepTimerSet); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + Playable media = controller.getMedia(); + if (item.getItemId() == android.R.id.home) { + Intent intent = new Intent(MediaplayerActivity.this, + MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + return true; + } else if (media != null) { + switch (item.getItemId()) { + case R.id.disable_sleeptimer_item: + if (controller.serviceAvailable()) { + AlertDialog.Builder stDialog = new AlertDialog.Builder(this); + stDialog.setTitle(R.string.sleep_timer_label); + stDialog.setMessage(getString(R.string.time_left_label) + + Converter.getDurationStringLong((int) controller + .getSleepTimerTimeLeft())); + stDialog.setPositiveButton( + R.string.disable_sleeptimer_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + dialog.dismiss(); + controller.disableSleepTimer(); + } + } + ); + stDialog.setNegativeButton(R.string.cancel_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + dialog.dismiss(); + } + } + ); + stDialog.create().show(); + } + break; + case R.id.set_sleeptimer_item: + if (controller.serviceAvailable()) { + int pickerStyle = (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Light) ? + R.style.AntennaPodBetterPickerThemeLight : R.style.AntennaPodBetterPickerThemeDark; + if (Build.VERSION.SDK_INT > 10) { // TODO remove this as soon as dialog is shown correctly on 2.3 + HmsPickerBuilder hpb = new HmsPickerBuilder() + .setStyleResId(pickerStyle) + .setFragmentManager(getSupportFragmentManager()); + + hpb.addHmsPickerDialogHandler(new HmsPickerDialogFragment.HmsPickerDialogHandler() { + @Override + public void onDialogHmsSet(int ref, int hours, int minutes, int seconds) { + if (controller != null && controller.serviceAvailable()) { + controller.setSleepTimer((hours * 3600 + minutes * 60 + seconds) * 1000); + } + } + }); + hpb.show(); + } else { + TimeDialog td = new TimeDialog(this, + R.string.set_sleeptimer_label, + R.string.set_sleeptimer_label) { + + @Override + public void onTimeEntered(long millis) { + controller.setSleepTimer(millis); + } + }; + td.show(); + } + break; + + } + case R.id.visit_website_item: + Uri uri = Uri.parse(media.getWebsiteLink()); + startActivity(new Intent(Intent.ACTION_VIEW, uri)); + break; + case R.id.support_item: + if (media instanceof FeedMedia) { + FeedItem feedItem = ((FeedMedia) media).getItem(); + DBTasks.flattrItemIfLoggedIn(this, feedItem); + } + break; + case R.id.share_link_item: + ShareUtils.shareLink(this, media.getWebsiteLink()); + break; + case R.id.skip_episode_item: + sendBroadcast(new Intent( + PlaybackService.ACTION_SKIP_CURRENT_EPISODE)); + break; + default: + return false; + + } + return true; + } else { + return false; + } + } + + @Override + protected void onResume() { + super.onResume(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Resuming Activity"); + StorageUtils.checkStorageAvailability(this); + controller.init(); + } + + /** + * Called by 'handleStatus()' when the PlaybackService is waiting for + * a video surface. + */ + protected abstract void onAwaitingVideoSurface(); + + protected abstract void postStatusMsg(int resId); + + protected abstract void clearStatusMsg(); + + protected void onPositionObserverUpdate() { + if (controller != null) { + int currentPosition = controller.getPosition(); + int duration = controller.getDuration(); + if (currentPosition != PlaybackService.INVALID_TIME + && duration != PlaybackService.INVALID_TIME + && controller.getMedia() != null) { + txtvPosition.setText(Converter + .getDurationStringLong(currentPosition)); + txtvLength.setText(Converter.getDurationStringLong(duration)); + updateProgressbarPosition(currentPosition, duration); + } else { + Log.w(TAG, + "Could not react to position observer update because of invalid time"); + } + } + } + + private void updateProgressbarPosition(int position, int duration) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Updating progressbar info"); + float progress = ((float) position) / duration; + sbPosition.setProgress((int) (progress * sbPosition.getMax())); + } + + /** + * Load information about the media that is going to be played or currently + * being played. This method will be called when the activity is connected + * to the PlaybackService to ensure that the activity has the right + * FeedMedia object. + */ + protected boolean loadMediaInfo() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading media info"); + Playable media = controller.getMedia(); + if (media != null) { + txtvPosition.setText(Converter.getDurationStringLong((media + .getPosition()))); + + if (media.getDuration() != 0) { + txtvLength.setText(Converter.getDurationStringLong(media + .getDuration())); + float progress = ((float) media.getPosition()) + / media.getDuration(); + sbPosition.setProgress((int) (progress * sbPosition.getMax())); + } + return true; + } else { + return false; + } + } + + protected void setupGUI() { + setContentView(getContentViewResourceId()); + sbPosition = (SeekBar) findViewById(R.id.sbPosition); + txtvPosition = (TextView) findViewById(R.id.txtvPosition); + txtvLength = (TextView) findViewById(R.id.txtvLength); + butPlay = (ImageButton) findViewById(R.id.butPlay); + butRev = (ImageButton) findViewById(R.id.butRev); + butFF = (ImageButton) findViewById(R.id.butFF); + + // SEEKBAR SETUP + + sbPosition.setOnSeekBarChangeListener(this); + + // BUTTON SETUP + + butPlay.setOnClickListener(controller.newOnPlayButtonClickListener()); + + if (butFF != null) { + butFF.setOnClickListener(controller.newOnFFButtonClickListener()); + } + if (butRev != null) { + butRev.setOnClickListener(controller.newOnRevButtonClickListener()); + } + + } + + protected abstract int getContentViewResourceId(); + + void handleError(int errorCode) { + final AlertDialog.Builder errorDialog = new AlertDialog.Builder(this); + errorDialog.setTitle(R.string.error_label); + errorDialog + .setMessage(MediaPlayerError.getErrorString(this, errorCode)); + errorDialog.setNeutralButton("OK", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + finish(); + } + } + ); + errorDialog.create().show(); + } + + float prog; + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, + boolean fromUser) { + if (controller != null) { + prog = controller.onSeekBarProgressChanged(seekBar, progress, fromUser, + txtvPosition); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + if (controller != null) { + controller.onSeekBarStartTrackingTouch(seekBar); + } + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + if (controller != null) { + controller.onSeekBarStopTrackingTouch(seekBar, prog); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java new file mode 100644 index 000000000..2c6d75cd8 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java @@ -0,0 +1,428 @@ +package de.danoeh.antennapod.activity; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.dialog.AuthenticationDialog; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.download.DownloadRequest; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.service.download.HttpDownloader; +import de.danoeh.antennapod.syndication.handler.FeedHandler; +import de.danoeh.antennapod.syndication.handler.FeedHandlerResult; +import de.danoeh.antennapod.syndication.handler.UnsupportedFeedtypeException; +import de.danoeh.antennapod.util.DownloadError; +import de.danoeh.antennapod.util.FileNameGenerator; +import de.danoeh.antennapod.util.StorageUtils; +import de.danoeh.antennapod.util.URLChecker; +import de.danoeh.antennapod.util.syndication.FeedDiscoverer; +import org.apache.commons.lang3.StringUtils; +import org.xml.sax.SAXException; + +import javax.xml.parsers.ParserConfigurationException; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * Downloads a feed from a feed URL and parses it. Subclasses can display the + * feed object that was parsed. This activity MUST be started with a given URL + * or an Exception will be thrown. + * <p/> + * If the feed cannot be downloaded or parsed, an error dialog will be displayed + * and the activity will finish as soon as the error dialog is closed. + */ +public abstract class OnlineFeedViewActivity extends ActionBarActivity { + private static final String TAG = "OnlineFeedViewActivity"; + public static final String ARG_FEEDURL = "arg.feedurl"; + + /** + * Optional argument: specify a title for the actionbar. + */ + public static final String ARG_TITLE = "title"; + + public static final int RESULT_ERROR = 2; + + private Feed feed; + private Map<String, String> alternateFeedUrls; + private Downloader downloader; + + private boolean isPaused; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + + if (getIntent() != null && getIntent().hasExtra(ARG_TITLE)) { + getSupportActionBar().setTitle(getIntent().getStringExtra(ARG_TITLE)); + } + + StorageUtils.checkStorageAvailability(this); + + final String feedUrl; + if (getIntent().hasExtra(ARG_FEEDURL)) { + feedUrl = getIntent().getStringExtra(ARG_FEEDURL); + } else if (StringUtils.equals(getIntent().getAction(), Intent.ACTION_SEND) + || StringUtils.equals(getIntent().getAction(), Intent.ACTION_VIEW)) { + feedUrl = (StringUtils.equals(getIntent().getAction(), Intent.ACTION_SEND)) + ? getIntent().getStringExtra(Intent.EXTRA_TEXT) : getIntent().getDataString(); + + getSupportActionBar().setTitle(R.string.add_new_feed_label); + } else { + throw new IllegalArgumentException( + "Activity must be started with feedurl argument!"); + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "Activity was started with url " + feedUrl); + setLoadingLayout(); + if (savedInstanceState == null) { + startFeedDownload(feedUrl, null, null); + } else { + startFeedDownload(feedUrl, savedInstanceState.getString("username"), savedInstanceState.getString("password")); + } + } + + @Override + protected void onResume() { + super.onResume(); + isPaused = false; + } + + @Override + protected void onPause() { + super.onPause(); + isPaused = true; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (feed != null && feed.getPreferences() != null) { + outState.putString("username", feed.getPreferences().getUsername()); + outState.putString("password", feed.getPreferences().getPassword()); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (downloader != null && !downloader.isFinished()) { + downloader.cancel(); + } + } + + private void resetIntent(String url, String title) { + Intent intent = new Intent(); + intent.putExtra(ARG_FEEDURL, url); + intent.putExtra(ARG_TITLE, title); + setIntent(intent); + } + + + private void onDownloadCompleted(final Downloader downloader) { + runOnUiThread(new Runnable() { + + @Override + public void run() { + if (BuildConfig.DEBUG) Log.d(TAG, "Download was completed"); + DownloadStatus status = downloader.getResult(); + if (status != null) { + if (!status.isCancelled()) { + if (status.isSuccessful()) { + parseFeed(); + } else if (status.getReason() == DownloadError.ERROR_UNAUTHORIZED) { + if (!isFinishing() && !isPaused) { + Dialog dialog = new FeedViewAuthenticationDialog(OnlineFeedViewActivity.this, + R.string.authentication_notification_title, downloader.getDownloadRequest().getSource()); + dialog.show(); + } + } else { + String errorMsg = status.getReason().getErrorString( + OnlineFeedViewActivity.this); + if (errorMsg != null + && status.getReasonDetailed() != null) { + errorMsg += " (" + + status.getReasonDetailed() + ")"; + } + showErrorDialog(errorMsg); + } + } + } else { + Log.wtf(TAG, + "DownloadStatus returned by Downloader was null"); + finish(); + } + } + }); + + } + + private void startFeedDownload(String url, String username, String password) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting feed download"); + url = URLChecker.prepareURL(url); + feed = new Feed(url, new Date()); + if (username != null && password != null) { + feed.setPreferences(new FeedPreferences(0, true, username, password)); + } + String fileUrl = new File(getExternalCacheDir(), + FileNameGenerator.generateFileName(feed.getDownload_url())) + .toString(); + feed.setFile_url(fileUrl); + final DownloadRequest request = new DownloadRequest(feed.getFile_url(), + feed.getDownload_url(), "OnlineFeed", 0, Feed.FEEDFILETYPE_FEED, username, password, true); + downloader = new HttpDownloader( + request); + new Thread() { + @Override + public void run() { + loadData(); + downloader.call(); + onDownloadCompleted(downloader); + } + }.start(); + + + } + + /** + * Displays a progress indicator. + */ + private void setLoadingLayout() { + RelativeLayout rl = new RelativeLayout(this); + RelativeLayout.LayoutParams rlLayoutParams = new RelativeLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT); + + ProgressBar pb = new ProgressBar(this); + pb.setIndeterminate(true); + RelativeLayout.LayoutParams pbLayoutParams = new RelativeLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + pbLayoutParams.addRule(RelativeLayout.CENTER_IN_PARENT); + rl.addView(pb, pbLayoutParams); + addContentView(rl, rlLayoutParams); + } + + private void parseFeed() { + if (feed == null || feed.getFile_url() == null && feed.isDownloaded()) { + throw new IllegalStateException( + "feed must be non-null and downloaded when parseFeed is called"); + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "Parsing feed"); + + Thread thread = new Thread() { + + @Override + public void run() { + String reasonDetailed = ""; + boolean successful = false; + FeedHandler handler = new FeedHandler(); + try { + FeedHandlerResult result = handler.parseFeed(feed); + feed = result.feed; + alternateFeedUrls = result.alternateFeedUrls; + successful = true; + } catch (SAXException e) { + e.printStackTrace(); + reasonDetailed = e.getMessage(); + } catch (IOException e) { + e.printStackTrace(); + reasonDetailed = e.getMessage(); + } catch (ParserConfigurationException e) { + e.printStackTrace(); + reasonDetailed = e.getMessage(); + } catch (UnsupportedFeedtypeException e) { + if (BuildConfig.DEBUG) Log.d(TAG, "Unsupported feed type detected"); + if (StringUtils.equalsIgnoreCase("html", e.getRootElement())) { + if (showFeedDiscoveryDialog(new File(feed.getFile_url()), feed.getDownload_url())) { + return; + } + } else { + e.printStackTrace(); + reasonDetailed = e.getMessage(); + } + } finally { + boolean rc = new File(feed.getFile_url()).delete(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Deleted feed source file. Result: " + rc); + } + + if (successful) { + beforeShowFeedInformation(feed, alternateFeedUrls); + runOnUiThread(new Runnable() { + @Override + public void run() { + showFeedInformation(feed, alternateFeedUrls); + } + }); + } else { + final String errorMsg = + DownloadError.ERROR_PARSER_EXCEPTION.getErrorString( + OnlineFeedViewActivity.this) + + " (" + reasonDetailed + ")"; + runOnUiThread(new Runnable() { + + @Override + public void run() { + showErrorDialog(errorMsg); + } + }); + } + } + }; + thread.start(); + } + + /** + * Can be used to load data asynchronously. + */ + protected void loadData() { + + } + + /** + * Called after the feed has been downloaded and parsed and before showFeedInformation is called. + * This method is executed on a background thread + */ + protected void beforeShowFeedInformation(Feed feed, Map<String, String> alternateFeedUrls) { + + } + + /** + * Called when feed parsed successfully. + * This method is executed on the GUI thread. + */ + protected void showFeedInformation(Feed feed, Map<String, String> alternateFeedUrls) { + + } + + private void showErrorDialog(String errorMsg) { + if (!isFinishing() && !isPaused) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.error_label); + if (errorMsg != null) { + builder.setMessage(getString(R.string.error_msg_prefix) + errorMsg); + } else { + builder.setMessage(R.string.error_msg_prefix); + } + builder.setNeutralButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + } + ); + builder.setOnCancelListener(new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + setResult(RESULT_ERROR); + finish(); + } + }); + builder.show(); + } + } + + private boolean showFeedDiscoveryDialog(File feedFile, String baseUrl) { + FeedDiscoverer fd = new FeedDiscoverer(); + final Map<String, String> urlsMap; + try { + urlsMap = fd.findLinks(feedFile, baseUrl); + if (urlsMap == null || urlsMap.isEmpty()) { + return false; + } + } catch (IOException e) { + e.printStackTrace(); + return false; + } + + runOnUiThread(new Runnable() { + @Override + public void run() { + if (isPaused || isFinishing()) { + return; + } + + final List<String> titles = new ArrayList<String>(); + final List<String> urls = new ArrayList<String>(); + + urls.addAll(urlsMap.keySet()); + for (String url : urls) { + titles.add(urlsMap.get(url)); + } + + final ArrayAdapter<String> adapter = new ArrayAdapter<String>(OnlineFeedViewActivity.this, R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles); + DialogInterface.OnClickListener onClickListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String selectedUrl = urls.get(which); + dialog.dismiss(); + resetIntent(selectedUrl, titles.get(which)); + startFeedDownload(selectedUrl, null, null); + } + }; + + AlertDialog.Builder ab = new AlertDialog.Builder(OnlineFeedViewActivity.this) + .setTitle(R.string.feeds_label) + .setCancelable(true) + .setOnCancelListener(new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + finish(); + } + }) + .setAdapter(adapter, onClickListener); + ab.show(); + } + }); + + + return true; + } + + private class FeedViewAuthenticationDialog extends AuthenticationDialog { + + private String feedUrl; + + public FeedViewAuthenticationDialog(Context context, int titleRes, String feedUrl) { + super(context, titleRes, true, false, null, null); + this.feedUrl = feedUrl; + } + + @Override + protected void onCancelled() { + super.onCancelled(); + finish(); + } + + @Override + protected void onConfirmed(String username, String password, boolean saveUsernamePassword) { + startFeedDownload(feedUrl, username, password); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java new file mode 100644 index 000000000..e09941abf --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java @@ -0,0 +1,134 @@ +package de.danoeh.antennapod.activity; + +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.view.MenuItemCompat; +import android.support.v7.app.ActionBarActivity; +import android.util.SparseBooleanArray; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.opml.OpmlElement; +import de.danoeh.antennapod.preferences.UserPreferences; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays the feeds that the OPML-Importer has read and lets the user choose + * which feeds he wants to import. + */ +public class OpmlFeedChooserActivity extends ActionBarActivity { + private static final String TAG = "OpmlFeedChooserActivity"; + + public static final String EXTRA_SELECTED_ITEMS = "de.danoeh.antennapod.selectedItems"; + + private Button butConfirm; + private Button butCancel; + private ListView feedlist; + private ArrayAdapter<String> listAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + + setContentView(R.layout.opml_selection); + butConfirm = (Button) findViewById(R.id.butConfirm); + butCancel = (Button) findViewById(R.id.butCancel); + feedlist = (ListView) findViewById(R.id.feedlist); + + feedlist.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + listAdapter = new ArrayAdapter<String>(this, + android.R.layout.simple_list_item_multiple_choice, + getTitleList()); + + feedlist.setAdapter(listAdapter); + + butCancel.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + setResult(RESULT_CANCELED); + finish(); + } + }); + + butConfirm.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + Intent intent = new Intent(); + SparseBooleanArray checked = feedlist.getCheckedItemPositions(); + + int checkedCount = 0; + // Get number of checked items + for (int i = 0; i < checked.size(); i++) { + if (checked.valueAt(i)) { + checkedCount++; + } + } + int[] selection = new int[checkedCount]; + for (int i = 0, collected = 0; collected < checkedCount; i++) { + if (checked.valueAt(i)) { + selection[collected] = checked.keyAt(i); + collected++; + } + } + intent.putExtra(EXTRA_SELECTED_ITEMS, selection); + setResult(RESULT_OK, intent); + finish(); + } + }); + + } + + private List<String> getTitleList() { + List<String> result = new ArrayList<String>(); + if (OpmlImportHolder.getReadElements() != null) { + for (OpmlElement element : OpmlImportHolder.getReadElements()) { + result.add(element.getText()); + } + + } + return result; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuItemCompat.setShowAsAction(menu.add(Menu.NONE, R.id.select_all_item, Menu.NONE, + R.string.select_all_label), + MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); + + MenuItemCompat.setShowAsAction(menu.add(Menu.NONE, R.id.deselect_all_item, Menu.NONE, + R.string.deselect_all_label), + MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.select_all_item: + selectAllItems(true); + return true; + case R.id.deselect_all_item: + selectAllItems(false); + return true; + default: + return false; + } + } + + private void selectAllItems(boolean b) { + for (int i = 0; i < feedlist.getCount(); i++) { + feedlist.setItemChecked(i, b); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java new file mode 100644 index 000000000..d3fd3949c --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java @@ -0,0 +1,90 @@ +package de.danoeh.antennapod.activity; + +import android.content.Intent; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.asynctask.OpmlFeedQueuer; +import de.danoeh.antennapod.asynctask.OpmlImportWorker; +import de.danoeh.antennapod.opml.OpmlElement; + +import java.io.Reader; +import java.util.ArrayList; + +/** + * Base activity for Opml Import - e.g. with code what to do afterwards + * */ +public class OpmlImportBaseActivity extends ActionBarActivity { + + private static final String TAG = "OpmlImportBaseActivity"; + private OpmlImportWorker importWorker; + + /** + * Handles the choices made by the user in the OpmlFeedChooserActivity and + * starts the OpmlFeedQueuer if necessary. + */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received result"); + if (resultCode == RESULT_CANCELED) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Activity was cancelled"); + if (finishWhenCanceled()) + finish(); + } else { + int[] selected = data + .getIntArrayExtra(OpmlFeedChooserActivity.EXTRA_SELECTED_ITEMS); + if (selected != null && selected.length > 0) { + OpmlFeedQueuer queuer = new OpmlFeedQueuer(this, selected) { + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + Intent intent = new Intent(OpmlImportBaseActivity.this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + + }; + queuer.executeAsync(); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "No items were selected"); + } + } + } + + /** Starts the import process. */ + protected void startImport(Reader reader) { + + if (reader != null) { + importWorker = new OpmlImportWorker(this, reader) { + + @Override + protected void onPostExecute(ArrayList<OpmlElement> result) { + super.onPostExecute(result); + if (result != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Parsing was successful"); + OpmlImportHolder.setReadElements(result); + startActivityForResult(new Intent( + OpmlImportBaseActivity.this, + OpmlFeedChooserActivity.class), 0); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Parser error occurred"); + } + } + }; + importWorker.executeAsync(); + } + } + + protected boolean finishWhenCanceled() { + return false; + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java new file mode 100644 index 000000000..16e663fac --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.activity; + +import android.app.AlertDialog; +import android.os.Bundle; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.util.LangUtils; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URL; + +/** Lets the user start the OPML-import process. */ +public class OpmlImportFromIntentActivity extends OpmlImportBaseActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + try { + URL mOpmlURL = new URL(getIntent().getData().toString()); + BufferedReader in = new BufferedReader(new InputStreamReader(mOpmlURL.openStream(), + LangUtils.UTF_8)); + startImport(in); + } catch (Exception e) { + new AlertDialog.Builder(this).setMessage("Cannot open XML - Reason: " + e.getMessage()).show(); + } + + } + + @Override + protected boolean finishWhenCanceled() { + return true; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java new file mode 100644 index 000000000..94f100321 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java @@ -0,0 +1,172 @@ +package de.danoeh.antennapod.activity; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.util.LangUtils; +import de.danoeh.antennapod.util.StorageUtils; + +import java.io.*; + +/** + * Lets the user start the OPML-import process from a path + */ +public class OpmlImportFromPathActivity extends OpmlImportBaseActivity { + public static final String IMPORT_DIR = "import/"; + private static final String TAG = "OpmlImportFromPathActivity"; + private TextView txtvPath; + private Button butStart; + private String importPath; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + setContentView(R.layout.opml_import); + + txtvPath = (TextView) findViewById(R.id.txtvPath); + butStart = (Button) findViewById(R.id.butStartImport); + + butStart.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + checkFolderForFiles(); + } + + }); + } + + @Override + protected void onResume() { + super.onResume(); + StorageUtils.checkStorageAvailability(this); + setImportPath(); + } + + /** + * Sets the importPath variable and makes txtvPath display the import + * directory. + */ + private void setImportPath() { + File importDir = UserPreferences.getDataFolder(this, IMPORT_DIR); + boolean success = true; + if (!importDir.exists()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Import directory doesn't exist. Creating..."); + success = importDir.mkdir(); + if (!success) { + Log.e(TAG, "Could not create directory"); + } + } + if (success) { + txtvPath.setText(importDir.toString()); + importPath = importDir.toString(); + } else { + txtvPath.setText(R.string.opml_directory_error); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + default: + return false; + } + } + + /** + * Looks at the contents of the import directory and decides what to do. If + * more than one file is in the directory, a dialog will be created to let + * the user choose which item to import + */ + private void checkFolderForFiles() { + File dir = new File(importPath); + if (dir.isDirectory()) { + File[] fileList = dir.listFiles(); + if (fileList.length == 1) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Found one file, choosing that one."); + startImport(fileList[0]); + } else if (fileList.length > 1) { + Log.w(TAG, "Import directory contains more than one file."); + askForFile(dir); + } else { + Log.e(TAG, "Import directory is empty"); + Toast toast = Toast + .makeText(this, R.string.opml_import_error_dir_empty, + Toast.LENGTH_LONG); + toast.show(); + } + } + } + + private void startImport(File file) { + Reader mReader = null; + try { + mReader = new InputStreamReader(new FileInputStream(file), + LangUtils.UTF_8); + if (BuildConfig.DEBUG) Log.d(TAG, "Parsing " + file.toString()); + startImport(mReader); + } catch (FileNotFoundException e) { + Log.d(TAG, "File not found which really should be there"); + // this should never happen as it is a file we have just chosen + } + } + + /** + * Asks the user to choose from a list of files in a directory and returns + * his choice. + */ + private void askForFile(File dir) { + final File[] fileList = dir.listFiles(); + String[] fileNames = dir.list(); + + AlertDialog.Builder dialog = new AlertDialog.Builder(this); + dialog.setTitle(R.string.choose_file_to_import_label); + dialog.setNeutralButton(android.R.string.cancel, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Dialog was cancelled"); + dialog.dismiss(); + } + }); + dialog.setItems(fileNames, new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + if (BuildConfig.DEBUG) + Log.d(TAG, "File at index " + which + " was chosen"); + dialog.dismiss(); + startImport(fileList[which]); + } + }); + dialog.create().show(); + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java new file mode 100644 index 000000000..ec53ed7b6 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java @@ -0,0 +1,29 @@ +package de.danoeh.antennapod.activity; + +import de.danoeh.antennapod.opml.OpmlElement; + +import java.util.ArrayList; + +/** + * Hold infos gathered by Ompl-Import + * <p/> + * Created with IntelliJ IDEA. + * User: ligi + * Date: 1/23/13 + * Time: 2:15 PM + */ +public class OpmlImportHolder { + + private static ArrayList<OpmlElement> readElements; + + public static ArrayList<OpmlElement> getReadElements() { + return readElements; + } + + public static void setReadElements(ArrayList<OpmlElement> _readElements) { + readElements = _readElements; + } + + +} + diff --git a/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java new file mode 100644 index 000000000..cd6731c02 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java @@ -0,0 +1,513 @@ +package de.danoeh.antennapod.activity; + +import android.annotation.SuppressLint; +import android.app.ActionBar; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources.Theme; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiManager; +import android.os.Bundle; +import android.preference.CheckBoxPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceChangeListener; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceScreen; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.FlattrClickWorker; +import de.danoeh.antennapod.asynctask.OpmlExportWorker; +import de.danoeh.antennapod.dialog.AuthenticationDialog; +import de.danoeh.antennapod.dialog.AutoFlattrPreferenceDialog; +import de.danoeh.antennapod.dialog.GpodnetSetHostnameDialog; +import de.danoeh.antennapod.dialog.VariableSpeedDialog; +import de.danoeh.antennapod.preferences.GpodnetPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.util.flattr.FlattrStatus; +import de.danoeh.antennapod.util.flattr.FlattrUtils; +import de.danoeh.antennapod.util.flattr.SimpleFlattrThing; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * The main preference activity + */ +public class PreferenceActivity extends android.preference.PreferenceActivity { + private static final String TAG = "PreferenceActivity"; + + private static final String PREF_FLATTR_THIS_APP = "prefFlattrThisApp"; + private static final String PREF_FLATTR_SETTINGS = "prefFlattrSettings"; + private static final String PREF_FLATTR_AUTH = "pref_flattr_authenticate"; + private static final String PREF_FLATTR_REVOKE = "prefRevokeAccess"; + private static final String PREF_AUTO_FLATTR_PREFS = "prefAutoFlattrPrefs"; + private static final String PREF_OPML_EXPORT = "prefOpmlExport"; + private static final String PREF_ABOUT = "prefAbout"; + private static final String PREF_CHOOSE_DATA_DIR = "prefChooseDataDir"; + private static final String AUTO_DL_PREF_SCREEN = "prefAutoDownloadSettings"; + private static final String PREF_PLAYBACK_SPEED_LAUNCHER = "prefPlaybackSpeedLauncher"; + + private static final String PREF_GPODNET_LOGIN = "pref_gpodnet_authenticate"; + private static final String PREF_GPODNET_SETLOGIN_INFORMATION = "pref_gpodnet_setlogin_information"; + private static final String PREF_GPODNET_LOGOUT = "pref_gpodnet_logout"; + private static final String PREF_GPODNET_HOSTNAME = "pref_gpodnet_hostname"; + + private CheckBoxPreference[] selectedNetworks; + + @SuppressLint("NewApi") + @SuppressWarnings("deprecation") + @Override + public void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + + if (android.os.Build.VERSION.SDK_INT >= 11) { + @SuppressLint("AppCompatMethod") ActionBar ab = getActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + } + + addPreferencesFromResource(R.xml.preferences); + findPreference(PREF_FLATTR_THIS_APP).setOnPreferenceClickListener( + new OnPreferenceClickListener() { + + @Override + public boolean onPreferenceClick(Preference preference) { + new FlattrClickWorker(PreferenceActivity.this, + new SimpleFlattrThing(PreferenceActivity.this.getString(R.string.app_name), + FlattrUtils.APP_URL, + new FlattrStatus(FlattrStatus.STATUS_QUEUE) + ) + ).executeAsync(); + + return true; + } + } + ); + + findPreference(PREF_FLATTR_REVOKE).setOnPreferenceClickListener( + new OnPreferenceClickListener() { + + @Override + public boolean onPreferenceClick(Preference preference) { + FlattrUtils.revokeAccessToken(PreferenceActivity.this); + checkItemVisibility(); + return true; + } + + } + ); + + findPreference(PREF_ABOUT).setOnPreferenceClickListener( + new OnPreferenceClickListener() { + + @Override + public boolean onPreferenceClick(Preference preference) { + PreferenceActivity.this.startActivity(new Intent( + PreferenceActivity.this, AboutActivity.class)); + return true; + } + + } + ); + + findPreference(PREF_OPML_EXPORT).setOnPreferenceClickListener( + new OnPreferenceClickListener() { + + @Override + public boolean onPreferenceClick(Preference preference) { + new OpmlExportWorker(PreferenceActivity.this) + .executeAsync(); + + return true; + } + } + ); + + findPreference(PREF_CHOOSE_DATA_DIR).setOnPreferenceClickListener( + new OnPreferenceClickListener() { + + @Override + public boolean onPreferenceClick(Preference preference) { + startActivityForResult( + new Intent(PreferenceActivity.this, + DirectoryChooserActivity.class), + DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED + ); + return true; + } + } + ); + findPreference(UserPreferences.PREF_THEME) + .setOnPreferenceChangeListener( + new OnPreferenceChangeListener() { + + @Override + public boolean onPreferenceChange( + Preference preference, Object newValue) { + Intent i = getIntent(); + i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK + | Intent.FLAG_ACTIVITY_NEW_TASK); + finish(); + startActivity(i); + return true; + } + } + ); + findPreference(UserPreferences.PREF_ENABLE_AUTODL) + .setOnPreferenceChangeListener(new OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + if (newValue instanceof Boolean) { + findPreference(UserPreferences.PREF_ENABLE_AUTODL_WIFI_FILTER).setEnabled((Boolean) newValue); + setSelectedNetworksEnabled((Boolean) newValue && UserPreferences.isEnableAutodownloadWifiFilter()); + } + return true; + } + }); + findPreference(UserPreferences.PREF_ENABLE_AUTODL_WIFI_FILTER) + .setOnPreferenceChangeListener( + new OnPreferenceChangeListener() { + + @Override + public boolean onPreferenceChange( + Preference preference, Object newValue) { + if (newValue instanceof Boolean) { + setSelectedNetworksEnabled((Boolean) newValue); + return true; + } else { + return false; + } + } + } + ); + findPreference(UserPreferences.PREF_EPISODE_CACHE_SIZE) + .setOnPreferenceChangeListener( + new OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object o) { + if (o instanceof String) { + setEpisodeCacheSizeText(UserPreferences.readEpisodeCacheSize((String) o)); + } + return true; + } + } + ); + findPreference(PREF_PLAYBACK_SPEED_LAUNCHER) + .setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + VariableSpeedDialog.showDialog(PreferenceActivity.this); + return true; + } + }); + findPreference(PREF_GPODNET_SETLOGIN_INFORMATION).setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + AuthenticationDialog dialog = new AuthenticationDialog(PreferenceActivity.this, + R.string.pref_gpodnet_setlogin_information_title, false, false, GpodnetPreferences.getUsername(), + null) { + + @Override + protected void onConfirmed(String username, String password, boolean saveUsernamePassword) { + GpodnetPreferences.setPassword(password); + } + }; + dialog.show(); + return true; + } + }); + findPreference(PREF_GPODNET_LOGOUT).setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + GpodnetPreferences.logout(); + Toast toast = Toast.makeText(PreferenceActivity.this, R.string.pref_gpodnet_logout_toast, Toast.LENGTH_SHORT); + toast.show(); + updateGpodnetPreferenceScreen(); + return true; + } + }); + findPreference(PREF_GPODNET_HOSTNAME).setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + GpodnetSetHostnameDialog.createDialog(PreferenceActivity.this).setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + updateGpodnetPreferenceScreen(); + } + }); + return true; + } + }); + + findPreference(PREF_AUTO_FLATTR_PREFS).setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + AutoFlattrPreferenceDialog.newAutoFlattrPreferenceDialog(PreferenceActivity.this, + new AutoFlattrPreferenceDialog.AutoFlattrPreferenceDialogInterface() { + @Override + public void onCancelled() { + + } + + @Override + public void onConfirmed(boolean autoFlattrEnabled, float autoFlattrValue) { + UserPreferences.setAutoFlattrSettings(PreferenceActivity.this, autoFlattrEnabled, autoFlattrValue); + checkItemVisibility(); + } + }); + return true; + } + }); + buildUpdateIntervalPreference(); + buildAutodownloadSelectedNetworsPreference(); + setSelectedNetworksEnabled(UserPreferences + .isEnableAutodownloadWifiFilter()); + + + } + + private void updateGpodnetPreferenceScreen() { + final boolean loggedIn = GpodnetPreferences.loggedIn(); + findPreference(PREF_GPODNET_LOGIN).setEnabled(!loggedIn); + findPreference(PREF_GPODNET_SETLOGIN_INFORMATION).setEnabled(loggedIn); + findPreference(PREF_GPODNET_LOGOUT).setEnabled(loggedIn); + findPreference(PREF_GPODNET_HOSTNAME).setSummary(GpodnetPreferences.getHostname()); + } + + private void buildUpdateIntervalPreference() { + ListPreference pref = (ListPreference) findPreference(UserPreferences.PREF_UPDATE_INTERVAL); + String[] values = getResources().getStringArray( + R.array.update_intervall_values); + String[] entries = new String[values.length]; + for (int x = 0; x < values.length; x++) { + Integer v = Integer.parseInt(values[x]); + switch (v) { + case 0: + entries[x] = getString(R.string.pref_update_interval_hours_manual); + break; + case 1: + entries[x] = v + + " " + + getString(R.string.pref_update_interval_hours_singular); + break; + default: + entries[x] = v + " " + + getString(R.string.pref_update_interval_hours_plural); + break; + + } + } + pref.setEntries(entries); + + } + + private void setSelectedNetworksEnabled(boolean b) { + if (selectedNetworks != null) { + for (Preference p : selectedNetworks) { + p.setEnabled(b); + } + } + } + + @Override + protected void onResume() { + super.onResume(); + checkItemVisibility(); + setEpisodeCacheSizeText(UserPreferences.getEpisodeCacheSize()); + setDataFolderText(); + updateGpodnetPreferenceScreen(); + } + + @SuppressWarnings("deprecation") + private void checkItemVisibility() { + + boolean hasFlattrToken = FlattrUtils.hasToken(); + + findPreference(PREF_FLATTR_SETTINGS).setEnabled(FlattrUtils.hasAPICredentials()); + findPreference(PREF_FLATTR_AUTH).setEnabled(!hasFlattrToken); + findPreference(PREF_FLATTR_REVOKE).setEnabled(hasFlattrToken); + findPreference(PREF_AUTO_FLATTR_PREFS).setEnabled(hasFlattrToken); + + findPreference(UserPreferences.PREF_ENABLE_AUTODL_WIFI_FILTER) + .setEnabled(UserPreferences.isEnableAutodownload()); + setSelectedNetworksEnabled(UserPreferences.isEnableAutodownload() + && UserPreferences.isEnableAutodownloadWifiFilter()); + + } + + private void setEpisodeCacheSizeText(int cacheSize) { + String s; + if (cacheSize == getResources().getInteger( + R.integer.episode_cache_size_unlimited)) { + s = getString(R.string.pref_episode_cache_unlimited); + } else { + s = Integer.toString(cacheSize) + + getString(R.string.episodes_suffix); + } + findPreference(UserPreferences.PREF_EPISODE_CACHE_SIZE).setSummary(s); + } + + private void setDataFolderText() { + File f = UserPreferences.getDataFolder(this, null); + if (f != null) { + findPreference(PREF_CHOOSE_DATA_DIR) + .setSummary(f.getAbsolutePath()); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + Intent destIntent = new Intent(this, MainActivity.class); + destIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(destIntent); + finish(); + return true; + default: + return false; + } + } + + @Override + protected void onApplyThemeResource(Theme theme, int resid, boolean first) { + theme.applyStyle(UserPreferences.getTheme(), true); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) { + String dir = data + .getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR); + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting data folder"); + UserPreferences.setDataFolder(dir); + } + } + + private void buildAutodownloadSelectedNetworsPreference() { + if (selectedNetworks != null) { + clearAutodownloadSelectedNetworsPreference(); + } + // get configured networks + WifiManager wifiservice = (WifiManager) getSystemService(Context.WIFI_SERVICE); + List<WifiConfiguration> networks = wifiservice.getConfiguredNetworks(); + + if (networks != null) { + selectedNetworks = new CheckBoxPreference[networks.size()]; + List<String> prefValues = Arrays.asList(UserPreferences + .getAutodownloadSelectedNetworks()); + PreferenceScreen prefScreen = (PreferenceScreen) findPreference(AUTO_DL_PREF_SCREEN); + OnPreferenceClickListener clickListener = new OnPreferenceClickListener() { + + @Override + public boolean onPreferenceClick(Preference preference) { + if (preference instanceof CheckBoxPreference) { + String key = preference.getKey(); + ArrayList<String> prefValuesList = new ArrayList<String>( + Arrays.asList(UserPreferences + .getAutodownloadSelectedNetworks()) + ); + boolean newValue = ((CheckBoxPreference) preference) + .isChecked(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Selected network " + key + + ". New state: " + newValue); + + int index = prefValuesList.indexOf(key); + if (index >= 0 && newValue == false) { + // remove network + prefValuesList.remove(index); + } else if (index < 0 && newValue == true) { + prefValuesList.add(key); + } + + UserPreferences.setAutodownloadSelectedNetworks( + PreferenceActivity.this, prefValuesList + .toArray(new String[prefValuesList + .size()]) + ); + return true; + } else { + return false; + } + } + }; + // create preference for each known network. attach listener and set + // value + for (int i = 0; i < networks.size(); i++) { + WifiConfiguration config = networks.get(i); + + CheckBoxPreference pref = new CheckBoxPreference(this); + String key = Integer.toString(config.networkId); + pref.setTitle(config.SSID); + pref.setKey(key); + pref.setOnPreferenceClickListener(clickListener); + pref.setPersistent(false); + pref.setChecked(prefValues.contains(key)); + selectedNetworks[i] = pref; + prefScreen.addPreference(pref); + } + } else { + Log.e(TAG, "Couldn't get list of configure Wi-Fi networks"); + } + } + + private void clearAutodownloadSelectedNetworsPreference() { + if (selectedNetworks != null) { + PreferenceScreen prefScreen = (PreferenceScreen) findPreference(AUTO_DL_PREF_SCREEN); + + for (int i = 0; i < selectedNetworks.length; i++) { + if (selectedNetworks[i] != null) { + prefScreen.removePreference(selectedNetworks[i]); + } + } + } + } + + @SuppressWarnings("deprecation") + @Override + public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, + Preference preference) { + super.onPreferenceTreeClick(preferenceScreen, preference); + if (preference != null) + if (preference instanceof PreferenceScreen) + if (((PreferenceScreen) preference).getDialog() != null) + ((PreferenceScreen) preference) + .getDialog() + .getWindow() + .getDecorView() + .setBackgroundDrawable( + this.getWindow().getDecorView() + .getBackground().getConstantState() + .newDrawable() + ); + return false; + } + + @Override + public void onBackPressed() { + // The default back button behavior has to be overwritten because changing the theme clears the back stack + Intent destIntent = new Intent(this, MainActivity.class); + destIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(destIntent); + finish(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java new file mode 100644 index 000000000..d8a137eb9 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java @@ -0,0 +1,75 @@ +package de.danoeh.antennapod.activity; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.util.StorageUtils; + +/** Is show if there is now external storage available. */ +public class StorageErrorActivity extends ActionBarActivity { + private static final String TAG = "StorageErrorActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + + setContentView(R.layout.storage_error); + } + + @Override + protected void onPause() { + super.onPause(); + try { + unregisterReceiver(mediaUpdate); + } catch (IllegalArgumentException e) { + + } + } + + @Override + protected void onResume() { + super.onResume(); + if (StorageUtils.storageAvailable(this)) { + leaveErrorState(); + } else { + registerReceiver(mediaUpdate, new IntentFilter( + Intent.ACTION_MEDIA_MOUNTED)); + } + } + + private void leaveErrorState() { + finish(); + startActivity(new Intent(this, MainActivity.class)); + } + + private BroadcastReceiver mediaUpdate = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), Intent.ACTION_MEDIA_MOUNTED)) { + if (intent.getBooleanExtra("read-only", true)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Media was mounted; Finishing activity"); + leaveErrorState(); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Media seemed to have been mounted read only"); + } + } + } + + }; + +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java new file mode 100644 index 000000000..81661a288 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java @@ -0,0 +1,359 @@ +package de.danoeh.antennapod.activity; + +import android.annotation.SuppressLint; +import android.content.Intent; +import android.graphics.drawable.ColorDrawable; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.util.Pair; +import android.view.MotionEvent; +import android.view.SurfaceHolder; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.SeekBar; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.service.playback.PlayerStatus; +import de.danoeh.antennapod.util.playback.ExternalMedia; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.view.AspectRatioVideoView; + +/** + * Activity for playing video files. + */ +public class VideoplayerActivity extends MediaplayerActivity { + private static final String TAG = "VideoplayerActivity"; + + /** + * True if video controls are currently visible. + */ + private boolean videoControlsShowing = true; + private boolean videoSurfaceCreated = false; + private VideoControlsHider videoControlsToggler; + + private LinearLayout videoOverlay; + private AspectRatioVideoView videoview; + private ProgressBar progressIndicator; + + @Override + protected void chooseTheme() { + setTheme(R.style.Theme_AntennaPod_Dark); + } + + @SuppressLint("AppCompatMethod") + @Override + protected void onCreate(Bundle savedInstanceState) { + if (Build.VERSION.SDK_INT >= 11) { + requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + } + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + super.onCreate(savedInstanceState); + getSupportActionBar().setBackgroundDrawable(new ColorDrawable(0x80000000)); + } + + @Override + protected void onPause() { + super.onPause(); + if (videoControlsToggler != null) { + videoControlsToggler.cancel(true); + } + if (controller != null && controller.getStatus() == PlayerStatus.PLAYING) { + controller.pause(); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (getIntent().getAction() != null + && getIntent().getAction().equals(Intent.ACTION_VIEW)) { + Intent intent = getIntent(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Received VIEW intent: " + + intent.getData().getPath()); + ExternalMedia media = new ExternalMedia(intent.getData().getPath(), + MediaType.VIDEO); + Intent launchIntent = new Intent(this, PlaybackService.class); + launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); + launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, + true); + launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, false); + launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, + true); + startService(launchIntent); + } + } + + @Override + protected boolean loadMediaInfo() { + if (!super.loadMediaInfo()) { + return false; + } + Playable media = controller.getMedia(); + if (media != null) { + getSupportActionBar().setSubtitle(media.getEpisodeTitle()); + getSupportActionBar().setTitle(media.getFeedTitle()); + return true; + } + + return false; + } + + @Override + protected void setupGUI() { + super.setupGUI(); + videoOverlay = (LinearLayout) findViewById(R.id.overlay); + videoview = (AspectRatioVideoView) findViewById(R.id.videoview); + progressIndicator = (ProgressBar) findViewById(R.id.progressIndicator); + videoview.getHolder().addCallback(surfaceHolderCallback); + videoview.setOnTouchListener(onVideoviewTouched); + + setupVideoControlsToggler(); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + @Override + protected void onAwaitingVideoSurface() { + if (videoSurfaceCreated) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Videosurface already created, setting videosurface now"); + + Pair<Integer, Integer> videoSize = controller.getVideoSize(); + if (videoSize != null && videoSize.first > 0 && videoSize.second > 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Width,height of video: " + videoSize.first + ", " + videoSize.second); + videoview.setVideoSize(videoSize.first, videoSize.second); + } else { + Log.e(TAG, "Could not determine video size"); + } + controller.setVideoSurface(videoview.getHolder()); + } + } + + @Override + protected void postStatusMsg(int resId) { + if (resId == R.string.player_preparing_msg) { + progressIndicator.setVisibility(View.VISIBLE); + } else { + progressIndicator.setVisibility(View.INVISIBLE); + } + + } + + @Override + protected void clearStatusMsg() { + progressIndicator.setVisibility(View.INVISIBLE); + } + + View.OnTouchListener onVideoviewTouched = new View.OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + if (videoControlsToggler != null) { + videoControlsToggler.cancel(true); + } + toggleVideoControlsVisibility(); + if (videoControlsShowing) { + setupVideoControlsToggler(); + } + + return true; + } else { + return false; + } + } + }; + + @SuppressLint("NewApi") + void setupVideoControlsToggler() { + if (videoControlsToggler != null) { + videoControlsToggler.cancel(true); + } + videoControlsToggler = new VideoControlsHider(); + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + videoControlsToggler + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + videoControlsToggler.execute(); + } + } + + private void toggleVideoControlsVisibility() { + if (videoControlsShowing) { + getSupportActionBar().hide(); + hideVideoControls(); + } else { + getSupportActionBar().show(); + showVideoControls(); + } + videoControlsShowing = !videoControlsShowing; + } + + /** + * Hides the videocontrols after a certain period of time. + */ + public class VideoControlsHider extends AsyncTask<Void, Void, Void> { + @Override + protected void onCancelled() { + videoControlsToggler = null; + } + + @Override + protected void onPostExecute(Void result) { + videoControlsToggler = null; + } + + private static final int WAITING_INTERVALL = 5000; + private static final String TAG = "VideoControlsToggler"; + + @Override + protected void onProgressUpdate(Void... values) { + if (videoControlsShowing) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Hiding video controls"); + getSupportActionBar().hide(); + hideVideoControls(); + videoControlsShowing = false; + } + } + + @Override + protected Void doInBackground(Void... params) { + try { + Thread.sleep(WAITING_INTERVALL); + } catch (InterruptedException e) { + return null; + } + publishProgress(); + return null; + } + + } + + private final SurfaceHolder.Callback surfaceHolderCallback = new SurfaceHolder.Callback() { + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, + int height) { + holder.setFixedSize(width, height); + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Videoview holder created"); + videoSurfaceCreated = true; + if (controller.getStatus() == PlayerStatus.PLAYING) { + if (controller.serviceAvailable()) { + controller.setVideoSurface(holder); + } else { + Log.e(TAG, + "Could'nt attach surface to mediaplayer - reference to service was null"); + } + } + + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Videosurface was destroyed"); + videoSurfaceCreated = false; + controller.notifyVideoSurfaceAbandoned(); + } + }; + + + @Override + protected void onReloadNotification(int notificationCode) { + if (notificationCode == PlaybackService.EXTRA_CODE_AUDIO) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "ReloadNotification received, switching to Audioplayer now"); + finish(); + startActivity(new Intent(this, AudioplayerActivity.class)); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + super.onStartTrackingTouch(seekBar); + if (videoControlsToggler != null) { + videoControlsToggler.cancel(true); + } + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + super.onStopTrackingTouch(seekBar); + setupVideoControlsToggler(); + } + + @Override + protected void onBufferStart() { + progressIndicator.setVisibility(View.VISIBLE); + } + + @Override + protected void onBufferEnd() { + progressIndicator.setVisibility(View.INVISIBLE); + } + + @SuppressLint("NewApi") + private void showVideoControls() { + videoOverlay.setVisibility(View.VISIBLE); + butPlay.setVisibility(View.VISIBLE); + final Animation animation = AnimationUtils.loadAnimation(this, + R.anim.fade_in); + if (animation != null) { + videoOverlay.startAnimation(animation); + butPlay.startAnimation(animation); + } + if (Build.VERSION.SDK_INT >= 14) { + videoview.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); + } + } + + @SuppressLint("NewApi") + private void hideVideoControls() { + final Animation animation = AnimationUtils.loadAnimation(this, + R.anim.fade_out); + if (animation != null) { + videoOverlay.startAnimation(animation); + butPlay.startAnimation(animation); + } + if (Build.VERSION.SDK_INT >= 14) { + videoview.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE); + } + videoOverlay.setVisibility(View.GONE); + butPlay.setVisibility(View.GONE); + } + + @Override + protected int getContentViewResourceId() { + return R.layout.videoplayer_activity; + } + + + @Override + protected void setScreenOn(boolean enable) { + super.setScreenOn(enable); + if (enable) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java new file mode 100644 index 000000000..6a60f65fe --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java @@ -0,0 +1,372 @@ +package de.danoeh.antennapod.activity.gpoddernet; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.NavUtils; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.*; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.preferences.GpodnetPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.GpodnetSyncService; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Guides the user through the authentication process + * Step 1: Request username and password from user + * Step 2: Choose device from a list of available devices or create a new one + * Step 3: Choose from a list of actions + */ +public class GpodnetAuthenticationActivity extends ActionBarActivity { + private static final String TAG = "GpodnetAuthenticationActivity"; + + private static final String CURRENT_STEP = "current_step"; + + private ViewFlipper viewFlipper; + + private static final int STEP_DEFAULT = -1; + private static final int STEP_LOGIN = 0; + private static final int STEP_DEVICE = 1; + private static final int STEP_FINISH = 2; + + private int currentStep = -1; + + private GpodnetService service; + private volatile String username; + private volatile String password; + private volatile GpodnetDevice selectedDevice; + + View[] views; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(UserPreferences.getTheme()); + super.onCreate(savedInstanceState); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setContentView(R.layout.gpodnetauth_activity); + service = new GpodnetService(); + + viewFlipper = (ViewFlipper) findViewById(R.id.viewflipper); + LayoutInflater inflater = (LayoutInflater) + getSystemService(Context.LAYOUT_INFLATER_SERVICE); + views = new View[]{ + inflater.inflate(R.layout.gpodnetauth_credentials, viewFlipper, false), + inflater.inflate(R.layout.gpodnetauth_device, viewFlipper, false), + inflater.inflate(R.layout.gpodnetauth_finish, viewFlipper, false) + }; + for (View view : views) { + viewFlipper.addView(view); + } + advance(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (service != null) { + service.shutdown(); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + } + + private void setupLoginView(View view) { + final EditText username = (EditText) view.findViewById(R.id.etxtUsername); + final EditText password = (EditText) view.findViewById(R.id.etxtPassword); + final Button login = (Button) view.findViewById(R.id.butLogin); + final TextView txtvError = (TextView) view.findViewById(R.id.txtvError); + final ProgressBar progressBar = (ProgressBar) view.findViewById(R.id.progBarLogin); + + login.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + + final String usernameStr = username.getText().toString(); + final String passwordStr = password.getText().toString(); + + if (BuildConfig.DEBUG) Log.d(TAG, "Checking login credentials"); + new AsyncTask<GpodnetService, Void, Void>() { + + volatile Exception exception; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + login.setEnabled(false); + progressBar.setVisibility(View.VISIBLE); + txtvError.setVisibility(View.GONE); + + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + login.setEnabled(true); + progressBar.setVisibility(View.GONE); + + if (exception == null) { + advance(); + } else { + txtvError.setText(exception.getMessage()); + txtvError.setVisibility(View.VISIBLE); + } + } + + @Override + protected Void doInBackground(GpodnetService... params) { + try { + params[0].authenticate(usernameStr, passwordStr); + GpodnetAuthenticationActivity.this.username = usernameStr; + GpodnetAuthenticationActivity.this.password = passwordStr; + } catch (GpodnetServiceException e) { + e.printStackTrace(); + exception = e; + } + return null; + } + }.execute(service); + } + }); + } + + private void setupDeviceView(View view) { + final EditText deviceID = (EditText) view.findViewById(R.id.etxtDeviceID); + final EditText caption = (EditText) view.findViewById(R.id.etxtCaption); + final Button createNewDevice = (Button) view.findViewById(R.id.butCreateNewDevice); + final Button chooseDevice = (Button) view.findViewById(R.id.butChooseExistingDevice); + final TextView txtvError = (TextView) view.findViewById(R.id.txtvError); + final ProgressBar progBarCreateDevice = (ProgressBar) view.findViewById(R.id.progbarCreateDevice); + final Spinner spinnerDevices = (Spinner) view.findViewById(R.id.spinnerChooseDevice); + + + // load device list + final AtomicReference<List<GpodnetDevice>> devices = new AtomicReference<List<GpodnetDevice>>(); + new AsyncTask<GpodnetService, Void, List<GpodnetDevice>>() { + + private volatile Exception exception; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + chooseDevice.setEnabled(false); + spinnerDevices.setEnabled(false); + createNewDevice.setEnabled(false); + } + + @Override + protected void onPostExecute(List<GpodnetDevice> gpodnetDevices) { + super.onPostExecute(gpodnetDevices); + if (gpodnetDevices != null) { + List<String> deviceNames = new ArrayList<String>(); + for (GpodnetDevice device : gpodnetDevices) { + deviceNames.add(device.getCaption()); + } + spinnerDevices.setAdapter(new ArrayAdapter<String>(GpodnetAuthenticationActivity.this, + android.R.layout.simple_spinner_dropdown_item, deviceNames)); + spinnerDevices.setEnabled(true); + if (!deviceNames.isEmpty()) { + chooseDevice.setEnabled(true); + } + devices.set(gpodnetDevices); + createNewDevice.setEnabled(true); + } + } + + @Override + protected List<GpodnetDevice> doInBackground(GpodnetService... params) { + try { + return params[0].getDevices(username); + } catch (GpodnetServiceException e) { + e.printStackTrace(); + exception = e; + return null; + } + } + }.execute(service); + + + createNewDevice.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (checkDeviceIDText(deviceID, txtvError, devices.get())) { + final String deviceStr = deviceID.getText().toString(); + final String captionStr = caption.getText().toString(); + + new AsyncTask<GpodnetService, Void, GpodnetDevice>() { + + private volatile Exception exception; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + createNewDevice.setEnabled(false); + chooseDevice.setEnabled(false); + progBarCreateDevice.setVisibility(View.VISIBLE); + txtvError.setVisibility(View.GONE); + } + + @Override + protected void onPostExecute(GpodnetDevice result) { + super.onPostExecute(result); + createNewDevice.setEnabled(true); + chooseDevice.setEnabled(true); + progBarCreateDevice.setVisibility(View.GONE); + if (exception == null) { + selectedDevice = result; + advance(); + } else { + txtvError.setText(exception.getMessage()); + txtvError.setVisibility(View.VISIBLE); + } + } + + @Override + protected GpodnetDevice doInBackground(GpodnetService... params) { + try { + params[0].configureDevice(username, deviceStr, captionStr, GpodnetDevice.DeviceType.MOBILE); + return new GpodnetDevice(deviceStr, captionStr, GpodnetDevice.DeviceType.MOBILE.toString(), 0); + } catch (GpodnetServiceException e) { + e.printStackTrace(); + exception = e; + } + return null; + } + }.execute(service); + } + } + }); + + deviceID.setText(generateDeviceID()); + chooseDevice.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final int position = spinnerDevices.getSelectedItemPosition(); + if (position != AdapterView.INVALID_POSITION) { + selectedDevice = devices.get().get(position); + advance(); + } + } + }); + } + + + private String generateDeviceID() { + final int DEVICE_ID_LENGTH = 10; + StringBuilder buffer = new StringBuilder(DEVICE_ID_LENGTH); + SecureRandom random = new SecureRandom(); + for (int i = 0; i < DEVICE_ID_LENGTH; i++) { + buffer.append(random.nextInt(10)); + + } + return buffer.toString(); + } + + private boolean checkDeviceIDText(EditText deviceID, TextView txtvError, List<GpodnetDevice> devices) { + String text = deviceID.getText().toString(); + if (text.length() == 0) { + txtvError.setText(R.string.gpodnetauth_device_errorEmpty); + txtvError.setVisibility(View.VISIBLE); + return false; + } else { + if (devices != null) { + for (GpodnetDevice device : devices) { + if (device.getId().equals(text)) { + txtvError.setText(R.string.gpodnetauth_device_errorAlreadyUsed); + txtvError.setVisibility(View.VISIBLE); + return false; + } + } + txtvError.setVisibility(View.GONE); + return true; + } + return true; + } + + } + + private void setupFinishView(View view) { + final Button sync = (Button) view.findViewById(R.id.butSyncNow); + final Button back = (Button) view.findViewById(R.id.butGoMainscreen); + + sync.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + GpodnetSyncService.sendSyncIntent(GpodnetAuthenticationActivity.this); + finish(); + } + }); + back.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(GpodnetAuthenticationActivity.this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } + }); + } + + private void writeLoginCredentials() { + if (BuildConfig.DEBUG) Log.d(TAG, "Writing login credentials"); + GpodnetPreferences.setUsername(username); + GpodnetPreferences.setPassword(password); + GpodnetPreferences.setDeviceID(selectedDevice.getId()); + } + + private void advance() { + if (currentStep < STEP_FINISH) { + + View view = views[currentStep + 1]; + if (currentStep == STEP_DEFAULT) { + setupLoginView(view); + } else if (currentStep == STEP_LOGIN) { + if (username == null || password == null) { + throw new IllegalStateException("Username and password must not be null here"); + } else { + setupDeviceView(view); + } + } else if (currentStep == STEP_DEVICE) { + if (selectedDevice == null) { + throw new IllegalStateException("Device must not be null here"); + } else { + writeLoginCredentials(); + setupFinishView(view); + } + } + if (currentStep != STEP_DEFAULT) { + viewFlipper.showNext(); + } + currentStep++; + } else { + finish(); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java new file mode 100644 index 000000000..30ad2d03f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java @@ -0,0 +1,8 @@ +package de.danoeh.antennapod.adapter; + +import de.danoeh.antennapod.feed.FeedItem; + +public interface ActionButtonCallback { + /** Is called when the action button of a list item has been pressed. */ + abstract void onActionButtonPressed(FeedItem item); +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java new file mode 100644 index 000000000..1de071a73 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java @@ -0,0 +1,78 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.content.res.TypedArray; +import android.view.View; +import android.widget.ImageButton; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.storage.DownloadRequester; + +/** + * Utility methods for the action button that is displayed on the right hand side + * of a listitem. + */ +public class ActionButtonUtils { + + private final int[] labels; + private final TypedArray drawables; + private final Context context; + + public ActionButtonUtils(Context context) { + Validate.notNull(context); + + this.context = context; + drawables = context.obtainStyledAttributes(new int[]{ + R.attr.av_play, R.attr.navigation_cancel, R.attr.av_download, R.attr.navigation_chapters, R.attr.navigation_accept}); + labels = new int[]{R.string.play_label, R.string.cancel_download_label, R.string.download_label, R.string.mark_read_label}; + } + + /** + * Sets the displayed bitmap and content description of the given + * action button so that it matches the state of the FeedItem. + */ + public void configureActionButton(ImageButton butSecondary, FeedItem item) { + Validate.isTrue(butSecondary != null && item != null, "butSecondary or item was null"); + + final FeedMedia media = item.getMedia(); + if (media != null) { + final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media); + if (!media.isDownloaded()) { + if (isDownloadingMedia) { + // item is being downloaded + butSecondary.setVisibility(View.VISIBLE); + butSecondary.setImageDrawable(drawables + .getDrawable(1)); + butSecondary.setContentDescription(context.getString(labels[1])); + } else { + // item is not downloaded and not being downloaded + butSecondary.setVisibility(View.VISIBLE); + butSecondary.setImageDrawable(drawables.getDrawable(2)); + butSecondary.setContentDescription(context.getString(labels[2])); + } + } else { + // item is not being downloaded + butSecondary.setVisibility(View.VISIBLE); + if (media.isPlaying()) { + butSecondary.setImageDrawable(drawables.getDrawable(3)); + } else { + butSecondary + .setImageDrawable(drawables.getDrawable(0)); + } + butSecondary.setContentDescription(context.getString(labels[0])); + } + } else { + if (item.isRead()) { + butSecondary.setVisibility(View.INVISIBLE); + } else { + butSecondary.setVisibility(View.VISIBLE); + butSecondary.setImageDrawable(drawables.getDrawable(4)); + butSecondary.setContentDescription(context.getString(labels[3])); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java b/app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java new file mode 100644 index 000000000..f393fb7d7 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java @@ -0,0 +1,57 @@ +package de.danoeh.antennapod.adapter; + +import android.content.res.Resources; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.util.Converter; + +/** + * Utility methods for adapters + */ +public class AdapterUtils { + + private AdapterUtils() { + + } + + /** + * Updates the contents of the TextView that shows the current playback position and the ProgressBar. + */ + public static void updateEpisodePlaybackProgress(FeedItem item, Resources res, TextView txtvPos, ProgressBar episodeProgress) { + FeedMedia media = item.getMedia(); + episodeProgress.setVisibility(View.GONE); + if (media == null) { + txtvPos.setVisibility(View.GONE); + return; + } else { + txtvPos.setVisibility(View.VISIBLE); + } + + FeedItem.State state = item.getState(); + if (state == FeedItem.State.PLAYING + || state == FeedItem.State.IN_PROGRESS) { + if (media.getDuration() > 0) { + episodeProgress.setVisibility(View.VISIBLE); + episodeProgress + .setProgress((int) (((double) media + .getPosition()) / media.getDuration() * 100)); + txtvPos.setText(Converter + .getDurationStringLong(media.getDuration() + - media.getPosition())); + } + } else if (!media.isDownloaded()) { + txtvPos.setText(res.getString( + R.string.size_prefix) + + Converter.byteToString(media.getSize())); + } else { + txtvPos.setText(res.getString( + R.string.length_prefix) + + Converter.getDurationStringLong(media + .getDuration())); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java new file mode 100644 index 000000000..c12de6ebd --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java @@ -0,0 +1,180 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.util.Linkify; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.util.ChapterUtils; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.playback.Playable; + +import java.util.List; + +public class ChapterListAdapter extends ArrayAdapter<Chapter> { + + private static final String TAG = "ChapterListAdapter"; + + private List<Chapter> chapters; + private Playable media; + + private int defaultTextColor; + + public ChapterListAdapter(Context context, int textViewResourceId, + List<Chapter> objects, Playable media) { + super(context, textViewResourceId, objects); + this.chapters = objects; + this.media = media; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + + Chapter sc = getItem(position); + + // Inflate Layout + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + convertView = inflater.inflate(R.layout.simplechapter_item, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + defaultTextColor = holder.title.getTextColors().getDefaultColor(); + holder.start = (TextView) convertView.findViewById(R.id.txtvStart); + holder.link = (TextView) convertView.findViewById(R.id.txtvLink); + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + + } + + holder.title.setText(sc.getTitle()); + holder.start.setText(Converter.getDurationStringLong((int) sc + .getStart())); + if (sc.getLink() != null) { + holder.link.setVisibility(View.VISIBLE); + holder.link.setText(sc.getLink()); + Linkify.addLinks(holder.link, Linkify.WEB_URLS); + } else { + holder.link.setVisibility(View.GONE); + } + holder.link.setMovementMethod(null); + holder.link.setOnTouchListener(new OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent event) { + // from + // http://stackoverflow.com/questions/7236840/android-textview-linkify-intercepts-with-parent-view-gestures + TextView widget = (TextView) v; + Object text = widget.getText(); + if (text instanceof Spanned) { + Spannable buffer = (Spannable) text; + + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_DOWN) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + ClickableSpan[] link = buffer.getSpans(off, off, + ClickableSpan.class); + + if (link.length != 0) { + if (action == MotionEvent.ACTION_UP) { + link[0].onClick(widget); + } else if (action == MotionEvent.ACTION_DOWN) { + Selection.setSelection(buffer, + buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0])); + } + return true; + } + } + + } + + return false; + + } + }); + Chapter current = ChapterUtils.getCurrentChapter(media); + if (current != null) { + if (current == sc) { + holder.title.setTextColor(convertView.getResources().getColor( + R.color.bright_blue)); + holder.start.setTextColor(convertView.getResources().getColor( + R.color.bright_blue)); + } else { + holder.title.setTextColor(defaultTextColor); + holder.start.setTextColor(defaultTextColor); + } + } else { + Log.w(TAG, "Could not find out what the current chapter is."); + } + + return convertView; + } + + static class Holder { + TextView title; + TextView start; + TextView link; + } + + @Override + public int getCount() { + // ignore invalid chapters + int counter = 0; + for (Chapter chapter : chapters) { + if (!ignoreChapter(chapter)) { + counter++; + } + } + return counter; + } + + private boolean ignoreChapter(Chapter c) { + return media.getDuration() > 0 && media.getDuration() < c.getStart(); + } + + @Override + public Chapter getItem(int position) { + int i = 0; + for (Chapter chapter : chapters) { + if (!ignoreChapter(chapter)) { + if (i == position) { + return chapter; + } else { + i++; + } + } + } + return super.getItem(position); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java b/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java new file mode 100644 index 000000000..0c4cbe685 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java @@ -0,0 +1,57 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.widget.Toast; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; + +/** + * Default implementation of an ActionButtonCallback + */ +public class DefaultActionButtonCallback implements ActionButtonCallback { + private static final String TAG = "DefaultActionButtonCallback"; + + private final Context context; + + public DefaultActionButtonCallback(Context context) { + Validate.notNull(context); + this.context = context; + } + + @Override + public void onActionButtonPressed(final FeedItem item) { + + + if (item.hasMedia()) { + final FeedMedia media = item.getMedia(); + boolean isDownloading = DownloadRequester.getInstance().isDownloadingFile(media); + if (!isDownloading && !media.isDownloaded()) { + try { + DBTasks.downloadFeedItems(context, item); + Toast.makeText(context, R.string.status_downloading_label, Toast.LENGTH_SHORT).show(); + } catch (DownloadRequestException e) { + e.printStackTrace(); + DownloadRequestErrorDialogCreator.newRequestErrorDialog(context, e.getMessage()); + } + } else if (isDownloading) { + DownloadRequester.getInstance().cancelDownload(context, media); + Toast.makeText(context, R.string.download_cancelled_msg, Toast.LENGTH_SHORT).show(); + } else { // media is downloaded + DBTasks.playMedia(context, media, true, true, false); + } + } else { + if (!item.isRead()) { + DBWriter.markItemRead(context, item, true, true); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java new file mode 100644 index 000000000..2cc216227 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java @@ -0,0 +1,112 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.service.download.DownloadStatus; + +/** Displays a list of DownloadStatus entries. */ +public class DownloadLogAdapter extends BaseAdapter { + + private Context context; + + private ItemAccess itemAccess; + + public DownloadLogAdapter(Context context, ItemAccess itemAccess) { + super(); + this.itemAccess = itemAccess; + this.context = context; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + DownloadStatus status = getItem(position); + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.downloadlog_item, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.type = (TextView) convertView.findViewById(R.id.txtvType); + holder.date = (TextView) convertView.findViewById(R.id.txtvDate); + holder.successful = (TextView) convertView + .findViewById(R.id.txtvStatus); + holder.reason = (TextView) convertView + .findViewById(R.id.txtvReason); + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + if (status.getFeedfileType() == Feed.FEEDFILETYPE_FEED) { + holder.type.setText(R.string.download_type_feed); + } else if (status.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) { + holder.type.setText(R.string.download_type_media); + } else if (status.getFeedfileType() == FeedImage.FEEDFILETYPE_FEEDIMAGE) { + holder.type.setText(R.string.download_type_image); + } + if (status.getTitle() != null) { + holder.title.setText(status.getTitle()); + } else { + holder.title.setText(R.string.download_log_title_unknown); + } + holder.date.setText(DateUtils.getRelativeTimeSpanString( + status.getCompletionDate().getTime(), + System.currentTimeMillis(), 0, 0)); + if (status.isSuccessful()) { + holder.successful.setTextColor(convertView.getResources().getColor( + R.color.download_success_green)); + holder.successful.setText(R.string.download_successful); + holder.reason.setVisibility(View.GONE); + } else { + holder.successful.setTextColor(convertView.getResources().getColor( + R.color.download_failed_red)); + holder.successful.setText(R.string.download_failed); + String reasonText = status.getReason().getErrorString(context); + if (status.getReasonDetailed() != null) { + reasonText += ": " + status.getReasonDetailed(); + } + holder.reason.setText(reasonText); + holder.reason.setVisibility(View.VISIBLE); + } + + return convertView; + } + + static class Holder { + TextView title; + TextView type; + TextView date; + TextView successful; + TextView reason; + } + + @Override + public int getCount() { + return itemAccess.getCount(); + } + + @Override + public DownloadStatus getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + public static interface ItemAccess { + public int getCount(); + public DownloadStatus getItem(int position); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java new file mode 100644 index 000000000..ef5af67de --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java @@ -0,0 +1,122 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.util.Converter; + +/** + * Shows a list of downloaded episodes + */ +public class DownloadedEpisodesListAdapter extends BaseAdapter { + + private final Context context; + private final ItemAccess itemAccess; + + private final int imageSize; + + public DownloadedEpisodesListAdapter(Context context, ItemAccess itemAccess) { + super(); + this.context = context; + this.itemAccess = itemAccess; + this.imageSize = (int) context.getResources().getDimension(R.dimen.thumbnail_length_downloaded_item); + } + + @Override + public int getCount() { + return itemAccess.getCount(); + } + + @Override + public FeedItem getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + final FeedItem item = (FeedItem) getItem(position); + if (item == null) return null; + + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.downloaded_episodeslist_item, + parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.pubDate = (TextView) convertView + .findViewById(R.id.txtvPublished); + holder.butSecondary = (ImageButton) convertView + .findViewById(R.id.butSecondaryAction); + holder.imageView = (ImageView) convertView.findViewById(R.id.imgvImage); + holder.txtvSize = (TextView) convertView.findViewById(R.id.txtvSize); + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + holder.title.setText(item.getTitle()); + holder.pubDate.setText(DateUtils.formatDateTime(context, item.getPubDate().getTime(), DateUtils.FORMAT_SHOW_DATE)); + holder.txtvSize.setText(Converter.byteToString(item.getMedia().getSize())); + FeedItem.State state = item.getState(); + + if (state == FeedItem.State.PLAYING) { + holder.butSecondary.setEnabled(false); + } else { + holder.butSecondary.setEnabled(true); + } + + holder.butSecondary.setFocusable(false); + holder.butSecondary.setTag(item); + holder.butSecondary.setOnClickListener(secondaryActionListener); + + + PicassoProvider.getMediaMetadataPicassoInstance(context) + .load(item.getImageUri()) + .fit() + .into(holder.imageView); + + return convertView; + } + + private View.OnClickListener secondaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + FeedItem item = (FeedItem) v.getTag(); + itemAccess.onFeedItemSecondaryAction(item); + } + }; + + + static class Holder { + TextView title; + TextView pubDate; + ImageView imageView; + TextView txtvSize; + ImageButton butSecondary; + } + + public interface ItemAccess { + int getCount(); + + FeedItem getItem(int position); + + void onFeedItemSecondaryAction(FeedItem item); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java new file mode 100644 index 000000000..658af9e4e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java @@ -0,0 +1,142 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.TextView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.service.download.DownloadRequest; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.ThemeUtils; + +public class DownloadlistAdapter extends BaseAdapter { + + public static final int SELECTION_NONE = -1; + + private int selectedItemIndex; + private ItemAccess itemAccess; + private Context context; + + public DownloadlistAdapter(Context context, + ItemAccess itemAccess) { + super(); + this.selectedItemIndex = SELECTION_NONE; + this.context = context; + this.itemAccess = itemAccess; + } + + @Override + public int getCount() { + return itemAccess.getCount(); + } + + @Override + public Downloader getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + Downloader downloader = getItem(position); + DownloadRequest request = downloader.getDownloadRequest(); + // Inflate layout + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.downloadlist_item, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.message = (TextView) convertView + .findViewById(R.id.txtvMessage); + holder.downloaded = (TextView) convertView + .findViewById(R.id.txtvDownloaded); + holder.percent = (TextView) convertView + .findViewById(R.id.txtvPercent); + holder.progbar = (ProgressBar) convertView + .findViewById(R.id.progProgress); + holder.butSecondary = (ImageButton) convertView + .findViewById(R.id.butSecondaryAction); + + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + if (position == selectedItemIndex) { + convertView.setBackgroundColor(convertView.getResources().getColor( + ThemeUtils.getSelectionBackgroundColor())); + } else { + convertView.setBackgroundResource(0); + } + + holder.title.setText(request.getTitle()); + if (request.getStatusMsg() != 0) { + holder.message.setText(request.getStatusMsg()); + } + String strDownloaded = Converter.byteToString(request.getSoFar()); + if (request.getSize() != DownloadStatus.SIZE_UNKNOWN) { + strDownloaded += " / " + Converter.byteToString(request.getSize()); + holder.percent.setText(request.getProgressPercent() + "%"); + holder.progbar.setProgress(request.getProgressPercent()); + holder.percent.setVisibility(View.VISIBLE); + } else { + holder.progbar.setProgress(0); + holder.percent.setVisibility(View.INVISIBLE); + } + + holder.downloaded.setText(strDownloaded); + + holder.butSecondary.setFocusable(false); + holder.butSecondary.setTag(downloader); + holder.butSecondary.setOnClickListener(butSecondaryListener); + + return convertView; + } + + private View.OnClickListener butSecondaryListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + Downloader downloader = (Downloader) v.getTag(); + itemAccess.onSecondaryActionClick(downloader); + } + }; + + static class Holder { + TextView title; + TextView message; + TextView downloaded; + TextView percent; + ProgressBar progbar; + ImageButton butSecondary; + } + + public int getSelectedItemIndex() { + return selectedItemIndex; + } + + public void setSelectedItemIndex(int selectedItemIndex) { + this.selectedItemIndex = selectedItemIndex; + notifyDataSetChanged(); + } + + public interface ItemAccess { + public int getCount(); + + public Downloader getItem(int position); + + public void onSecondaryActionClick(Downloader downloader); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java new file mode 100644 index 000000000..3f666eb8b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java @@ -0,0 +1,306 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.content.res.TypedArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.Converter; + +/** + * Displays unread items and items in the queue in one combined list. The + * structure of this list is: [header] [queueItems] [header] [unreadItems]. + */ +public class ExternalEpisodesListAdapter extends BaseExpandableListAdapter { + private static final String TAG = "ExternalEpisodesListAdapter"; + + public static final int GROUP_POS_QUEUE = 0; + public static final int GROUP_POS_UNREAD = 1; + + private Context context; + private ItemAccess itemAccess; + + private ActionButtonCallback feedItemActionCallback; + private OnGroupActionClicked groupActionCallback; + + private final int imageSize; + + public ExternalEpisodesListAdapter(Context context, + ActionButtonCallback callback, + OnGroupActionClicked groupActionCallback, + ItemAccess itemAccess) { + super(); + this.context = context; + this.itemAccess = itemAccess; + this.feedItemActionCallback = callback; + this.groupActionCallback = groupActionCallback; + this.imageSize = (int) context.getResources().getDimension(R.dimen.thumbnail_length); + } + + @Override + public boolean areAllItemsEnabled() { + return true; + } + + @Override + public FeedItem getChild(int groupPosition, int childPosition) { + if (groupPosition == GROUP_POS_QUEUE) { + return itemAccess.getQueueItemAt(childPosition); + } else if (groupPosition == GROUP_POS_UNREAD) { + return itemAccess.getUnreadItemAt(childPosition); + } + return null; + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return childPosition; + } + + @Override + public View getChildView(int groupPosition, final int childPosition, + boolean isLastChild, View convertView, ViewGroup parent) { + Holder holder; + final FeedItem item = getChild(groupPosition, childPosition); + + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.external_itemlist_item, + parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.feedTitle = (TextView) convertView + .findViewById(R.id.txtvFeedname); + holder.lenSize = (TextView) convertView + .findViewById(R.id.txtvLenSize); + holder.downloadStatus = (ImageView) convertView + .findViewById(R.id.imgvDownloadStatus); + holder.feedImage = (ImageView) convertView + .findViewById(R.id.imgvFeedimage); + holder.butAction = (ImageButton) convertView + .findViewById(R.id.butAction); + holder.statusPlaying = (View) convertView + .findViewById(R.id.statusPlaying); + holder.episodeProgress = (ProgressBar) convertView + .findViewById(R.id.pbar_episode_progress); + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + holder.title.setText(item.getTitle()); + holder.feedTitle.setText(item.getFeed().getTitle()); + FeedItem.State state = item.getState(); + + if (groupPosition == GROUP_POS_QUEUE) { + switch (state) { + case PLAYING: + holder.statusPlaying.setVisibility(View.VISIBLE); + holder.episodeProgress.setVisibility(View.VISIBLE); + break; + case IN_PROGRESS: + holder.statusPlaying.setVisibility(View.GONE); + holder.episodeProgress.setVisibility(View.VISIBLE); + break; + case NEW: + holder.statusPlaying.setVisibility(View.GONE); + holder.episodeProgress.setVisibility(View.GONE); + break; + default: + holder.statusPlaying.setVisibility(View.GONE); + holder.episodeProgress.setVisibility(View.GONE); + break; + } + } else { + holder.statusPlaying.setVisibility(View.GONE); + holder.episodeProgress.setVisibility(View.GONE); + } + + FeedMedia media = item.getMedia(); + if (media != null) { + + if (state == FeedItem.State.PLAYING + || state == FeedItem.State.IN_PROGRESS) { + if (media.getDuration() > 0) { + holder.episodeProgress.setProgress((int) (((double) media + .getPosition()) / media.getDuration() * 100)); + holder.lenSize.setText(Converter + .getDurationStringLong(media.getDuration() + - media.getPosition())); + } + } else if (!media.isDownloaded()) { + holder.lenSize.setText(context.getString(R.string.size_prefix) + + Converter.byteToString(media.getSize())); + } else { + holder.lenSize.setText(context + .getString(R.string.length_prefix) + + Converter.getDurationStringLong(media.getDuration())); + } + + TypedArray drawables = context.obtainStyledAttributes(new int[]{ + R.attr.av_download, R.attr.navigation_refresh}); + final int[] labels = new int[]{R.string.status_downloaded_label, R.string.downloading_label}; + holder.lenSize.setVisibility(View.VISIBLE); + if (!media.isDownloaded()) { + if (DownloadRequester.getInstance().isDownloadingFile(media)) { + holder.downloadStatus.setVisibility(View.VISIBLE); + holder.downloadStatus.setImageDrawable(drawables + .getDrawable(1)); + holder.downloadStatus.setContentDescription(context.getString(labels[1])); + } else { + holder.downloadStatus.setVisibility(View.INVISIBLE); + } + } else { + holder.downloadStatus.setVisibility(View.VISIBLE); + holder.downloadStatus + .setImageDrawable(drawables.getDrawable(0)); + holder.downloadStatus.setContentDescription(context.getString(labels[0])); + } + } else { + holder.downloadStatus.setVisibility(View.INVISIBLE); + holder.lenSize.setVisibility(View.INVISIBLE); + } + + PicassoProvider.getMediaMetadataPicassoInstance(context) + .load(item.getImageUri()) + .fit() + .into(holder.feedImage); + + holder.butAction.setFocusable(false); + holder.butAction.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + feedItemActionCallback.onActionButtonPressed(item); + } + }); + + return convertView; + + } + + static class Holder { + TextView title; + TextView feedTitle; + TextView lenSize; + ImageView downloadStatus; + ImageView feedImage; + ImageButton butAction; + View statusPlaying; + ProgressBar episodeProgress; + } + + @Override + public int getChildrenCount(int groupPosition) { + if (groupPosition == GROUP_POS_QUEUE) { + return itemAccess.getQueueSize(); + } else if (groupPosition == GROUP_POS_UNREAD) { + return itemAccess.getUnreadItemsSize(); + } + return 0; + } + + @Override + public int getGroupCount() { + // Hide 'unread items' group if empty + if (itemAccess.getUnreadItemsSize() > 0) { + return 2; + } else { + return 1; + } + } + + @Override + public long getGroupId(int groupPosition) { + return groupPosition; + } + + @Override + public View getGroupView(final int groupPosition, boolean isExpanded, + View convertView, ViewGroup parent) { + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.feeditemlist_header, parent, false); + TextView headerTitle = (TextView) convertView + .findViewById(0); + ImageButton actionButton = (ImageButton) convertView + .findViewById(R.id.butAction); + TextView numItems = (TextView) convertView.findViewById(0); + + String headerString = null; + int childrenCount = 0; + + if (groupPosition == 0) { + headerString = context.getString(R.string.queue_label); + childrenCount = getChildrenCount(GROUP_POS_QUEUE); + } else { + headerString = context.getString(R.string.waiting_list_label); + childrenCount = getChildrenCount(GROUP_POS_UNREAD); + } + headerTitle.setText(headerString); + if (childrenCount <= 0) { + numItems.setVisibility(View.INVISIBLE); + } else { + numItems.setVisibility(View.VISIBLE); + numItems.setText(Integer.toString(childrenCount)); + } + actionButton.setFocusable(false); + actionButton.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + groupActionCallback.onClick(getGroupId(groupPosition)); + } + }); + return convertView; + } + + @Override + public boolean isEmpty() { + return itemAccess.getUnreadItemsSize() == 0 + && itemAccess.getQueueSize() == 0; + } + + @Override + public Object getGroup(int groupPosition) { + return null; + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return true; + } + + public interface OnGroupActionClicked { + public void onClick(long groupId); + } + + public static interface ItemAccess { + public int getQueueSize(); + + public int getUnreadItemsSize(); + + public FeedItem getQueueItemAt(int position); + + public FeedItem getUnreadItemAt(int position); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java new file mode 100644 index 000000000..357b5f8b4 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java @@ -0,0 +1,220 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.content.res.TypedArray; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.*; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.ThemeUtils; + +/** + * List adapter for items of feeds that the user has already subscribed to. + */ +public class FeedItemlistAdapter extends BaseAdapter { + + private ActionButtonCallback callback; + private final ItemAccess itemAccess; + private final Context context; + private boolean showFeedtitle; + private int selectedItemIndex; + private final ActionButtonUtils actionButtonUtils; + + public static final int SELECTION_NONE = -1; + + public FeedItemlistAdapter(Context context, + ItemAccess itemAccess, + ActionButtonCallback callback, boolean showFeedtitle) { + super(); + this.callback = callback; + this.context = context; + this.itemAccess = itemAccess; + this.showFeedtitle = showFeedtitle; + this.selectedItemIndex = SELECTION_NONE; + this.actionButtonUtils = new ActionButtonUtils(context); + } + + @Override + public int getCount() { + return itemAccess.getCount(); + + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public FeedItem getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public View getView(final int position, View convertView, ViewGroup parent) { + Holder holder; + final FeedItem item = getItem(position); + + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.feeditemlist_item, parent, false); + holder.title = (TextView) convertView + .findViewById(R.id.txtvItemname); + holder.lenSize = (TextView) convertView + .findViewById(R.id.txtvLenSize); + holder.butAction = (ImageButton) convertView + .findViewById(R.id.butSecondaryAction); + holder.published = (TextView) convertView + .findViewById(R.id.txtvPublished); + holder.inPlaylist = (ImageView) convertView + .findViewById(R.id.imgvInPlaylist); + holder.type = (ImageView) convertView.findViewById(R.id.imgvType); + holder.statusUnread = (View) convertView + .findViewById(R.id.statusUnread); + holder.episodeProgress = (ProgressBar) convertView + .findViewById(R.id.pbar_episode_progress); + + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + if (!(getItemViewType(position) == Adapter.IGNORE_ITEM_VIEW_TYPE)) { + convertView.setVisibility(View.VISIBLE); + if (position == selectedItemIndex) { + convertView.setBackgroundColor(convertView.getResources() + .getColor(ThemeUtils.getSelectionBackgroundColor())); + } else { + convertView.setBackgroundResource(0); + } + + StringBuilder buffer = new StringBuilder(item.getTitle()); + if (showFeedtitle) { + buffer.append("("); + buffer.append(item.getFeed().getTitle()); + buffer.append(")"); + } + holder.title.setText(buffer.toString()); + + FeedItem.State state = item.getState(); + switch (state) { + case PLAYING: + holder.statusUnread.setVisibility(View.GONE); + holder.episodeProgress.setVisibility(View.VISIBLE); + break; + case IN_PROGRESS: + holder.statusUnread.setVisibility(View.GONE); + holder.episodeProgress.setVisibility(View.VISIBLE); + break; + case NEW: + holder.statusUnread.setVisibility(View.VISIBLE); + break; + default: + holder.statusUnread.setVisibility(View.GONE); + break; + } + + holder.published.setText(DateUtils.formatDateTime(context, item.getPubDate().getTime(), DateUtils.FORMAT_SHOW_DATE)); + + + FeedMedia media = item.getMedia(); + if (media == null) { + holder.episodeProgress.setVisibility(View.GONE); + holder.inPlaylist.setVisibility(View.INVISIBLE); + holder.type.setVisibility(View.INVISIBLE); + holder.lenSize.setVisibility(View.INVISIBLE); + } else { + + AdapterUtils.updateEpisodePlaybackProgress(item, context.getResources(), holder.lenSize, holder.episodeProgress); + + if (((ItemAccess) itemAccess).isInQueue(item)) { + holder.inPlaylist.setVisibility(View.VISIBLE); + } else { + holder.inPlaylist.setVisibility(View.INVISIBLE); + } + + if (DownloadRequester.getInstance().isDownloadingFile( + item.getMedia())) { + holder.episodeProgress.setVisibility(View.VISIBLE); + holder.episodeProgress.setProgress(((ItemAccess) itemAccess).getItemDownloadProgressPercent(item)); + } + + TypedArray typeDrawables = context.obtainStyledAttributes( + new int[]{R.attr.type_audio, R.attr.type_video}); + final int[] labels = new int[]{R.string.media_type_audio_label, R.string.media_type_video_label}; + + MediaType mediaType = item.getMedia().getMediaType(); + if (mediaType == MediaType.AUDIO) { + holder.type.setImageDrawable(typeDrawables.getDrawable(0)); + holder.type.setContentDescription(context.getString(labels[0])); + holder.type.setVisibility(View.VISIBLE); + } else if (mediaType == MediaType.VIDEO) { + holder.type.setImageDrawable(typeDrawables.getDrawable(1)); + holder.type.setContentDescription(context.getString(labels[1])); + holder.type.setVisibility(View.VISIBLE); + } else { + holder.type.setImageBitmap(null); + holder.type.setVisibility(View.GONE); + } + } + + actionButtonUtils.configureActionButton(holder.butAction, item); + holder.butAction.setFocusable(false); + holder.butAction.setTag(item); + holder.butAction.setOnClickListener(butActionListener); + + } else { + convertView.setVisibility(View.GONE); + } + return convertView; + + } + + private final OnClickListener butActionListener = new OnClickListener() { + @Override + public void onClick(View v) { + FeedItem item = (FeedItem) v.getTag(); + callback.onActionButtonPressed(item); + } + }; + + static class Holder { + TextView title; + TextView published; + TextView lenSize; + ImageView type; + ImageView inPlaylist; + ImageButton butAction; + View statusUnread; + ProgressBar episodeProgress; + } + + public int getSelectedItemIndex() { + return selectedItemIndex; + } + + public void setSelectedItemIndex(int selectedItemIndex) { + this.selectedItemIndex = selectedItemIndex; + notifyDataSetChanged(); + } + + public static interface ItemAccess { + public boolean isInQueue(FeedItem item); + + int getItemDownloadProgressPercent(FeedItem item); + + int getCount(); + + FeedItem getItem(int position); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java new file mode 100644 index 000000000..c2c2285ac --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java @@ -0,0 +1,55 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; + +import java.util.List; + +/** + * List adapter for showing a list of FeedItems with their title and description. + */ +public class FeedItemlistDescriptionAdapter extends ArrayAdapter<FeedItem> { + + public FeedItemlistDescriptionAdapter(Context context, int resource, List<FeedItem> objects) { + super(context, resource, objects); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + + FeedItem item = getItem(position); + + // Inflate layout + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.itemdescription_listitem, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.description = (TextView) convertView.findViewById(R.id.txtvDescription); + + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + holder.title.setText(item.getTitle()); + if (item.getDescription() != null) { + holder.description.setText(item.getDescription()); + } + + return convertView; + } + + static class Holder { + TextView title; + TextView description; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java new file mode 100644 index 000000000..ef8e8ce07 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java @@ -0,0 +1,229 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.Feed; + +/** + * BaseAdapter for the navigation drawer + */ +public class NavListAdapter extends BaseAdapter { + public static final int VIEW_TYPE_COUNT = 3; + public static final int VIEW_TYPE_NAV = 0; + public static final int VIEW_TYPE_SECTION_DIVIDER = 1; + public static final int VIEW_TYPE_SUBSCRIPTION = 2; + + public static final int[] NAV_TITLES = {R.string.all_episodes_label, R.string.queue_label, R.string.downloads_label, R.string.playback_history_label, R.string.add_feed_label}; + + private final Drawable[] drawables; + + public static final int SUBSCRIPTION_OFFSET = 1 + NAV_TITLES.length; + + private ItemAccess itemAccess; + private Context context; + + public NavListAdapter(ItemAccess itemAccess, Context context) { + this.itemAccess = itemAccess; + this.context = context; + + TypedArray ta = context.obtainStyledAttributes(new int[]{R.attr.ic_new, R.attr.stat_playlist, + R.attr.av_download, R.attr.device_access_time, R.attr.content_new}); + drawables = new Drawable[]{ta.getDrawable(0), ta.getDrawable(1), ta.getDrawable(2), + ta.getDrawable(3), ta.getDrawable(4)}; + ta.recycle(); + } + + @Override + public int getCount() { + return NAV_TITLES.length + 1 + itemAccess.getCount(); + } + + @Override + public Object getItem(int position) { + int viewType = getItemViewType(position); + if (viewType == VIEW_TYPE_NAV) { + return context.getString(NAV_TITLES[position]); + } else if (viewType == VIEW_TYPE_SECTION_DIVIDER) { + return context.getString(R.string.podcasts_label); + } else { + return itemAccess.getItem(position); + } + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemViewType(int position) { + if (0 <= position && position < NAV_TITLES.length) { + return VIEW_TYPE_NAV; + } else if (position < NAV_TITLES.length + 1) { + return VIEW_TYPE_SECTION_DIVIDER; + } else { + return VIEW_TYPE_SUBSCRIPTION; + } + } + + @Override + public int getViewTypeCount() { + return VIEW_TYPE_COUNT; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + int viewType = getItemViewType(position); + View v = null; + if (viewType == VIEW_TYPE_NAV) { + v = getNavView((String) getItem(position), position, convertView, parent); + } else if (viewType == VIEW_TYPE_SECTION_DIVIDER) { + v = getSectionDividerView((String) getItem(position), position, convertView, parent); + } else { + v = getFeedView(position - SUBSCRIPTION_OFFSET, convertView, parent); + } + if (v != null) { + TextView txtvTitle = (TextView) v.findViewById(R.id.txtvTitle); + if (position == itemAccess.getSelectedItemIndex()) { + txtvTitle.setTypeface(null, Typeface.BOLD); + } else { + txtvTitle.setTypeface(null, Typeface.NORMAL); + } + } + return v; + } + + private View getNavView(String title, int position, View convertView, ViewGroup parent) { + NavHolder holder; + if (convertView == null) { + holder = new NavHolder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + convertView = inflater.inflate(R.layout.nav_listitem, parent, false); + + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.count = (TextView) convertView.findViewById(R.id.txtvCount); + holder.image = (ImageView) convertView.findViewById(R.id.imgvCover); + convertView.setTag(holder); + } else { + holder = (NavHolder) convertView.getTag(); + } + + holder.title.setText(title); + + if (NAV_TITLES[position] == R.string.queue_label) { + int queueSize = itemAccess.getQueueSize(); + if (queueSize > 0) { + holder.count.setVisibility(View.VISIBLE); + holder.count.setText(String.valueOf(queueSize)); + } else { + holder.count.setVisibility(View.GONE); + } + } else if (NAV_TITLES[position] == R.string.all_episodes_label) { + int unreadItems = itemAccess.getNumberOfUnreadItems(); + if (unreadItems > 0) { + holder.count.setVisibility(View.VISIBLE); + holder.count.setText(String.valueOf(unreadItems)); + } else { + holder.count.setVisibility(View.GONE); + } + } else { + holder.count.setVisibility(View.GONE); + } + + holder.image.setImageDrawable(drawables[position]); + + return convertView; + } + + private View getSectionDividerView(String title, int position, View convertView, ViewGroup parent) { + SectionHolder holder; + if (convertView == null) { + holder = new SectionHolder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + convertView = inflater.inflate(R.layout.nav_section_item, parent, false); + + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + convertView.setTag(holder); + } else { + holder = (SectionHolder) convertView.getTag(); + } + + holder.title.setText(title); + + convertView.setEnabled(false); + convertView.setOnClickListener(null); + + return convertView; + } + + private View getFeedView(int feedPos, View convertView, ViewGroup parent) { + FeedHolder holder; + Feed feed = itemAccess.getItem(feedPos); + + if (convertView == null) { + holder = new FeedHolder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + convertView = inflater.inflate(R.layout.nav_feedlistitem, parent, false); + + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.image = (ImageView) convertView.findViewById(R.id.imgvCover); + convertView.setTag(holder); + } else { + holder = (FeedHolder) convertView.getTag(); + } + + holder.title.setText(feed.getTitle()); + + PicassoProvider.getDefaultPicassoInstance(context) + .load(feed.getImageUri()) + .fit() + .into(holder.image); + + return convertView; + } + + static class NavHolder { + TextView title; + TextView count; + ImageView image; + } + + static class SectionHolder { + TextView title; + } + + static class FeedHolder { + TextView title; + ImageView image; + } + + + public interface ItemAccess { + public int getCount(); + + public Feed getItem(int position); + + public int getSelectedItemIndex(); + + public int getQueueSize(); + + public int getNumberOfUnreadItems(); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java new file mode 100644 index 000000000..8abe49133 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java @@ -0,0 +1,170 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.Converter; + +/** + * List adapter for the list of new episodes + */ +public class NewEpisodesListAdapter extends BaseAdapter { + + private final Context context; + private final ItemAccess itemAccess; + private final ActionButtonCallback actionButtonCallback; + private final ActionButtonUtils actionButtonUtils; + + public NewEpisodesListAdapter(Context context, ItemAccess itemAccess, ActionButtonCallback actionButtonCallback) { + super(); + this.context = context; + this.itemAccess = itemAccess; + this.actionButtonUtils = new ActionButtonUtils(context); + this.actionButtonCallback = actionButtonCallback; + } + + @Override + public int getCount() { + return itemAccess.getCount(); + } + + @Override + public Object getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + final FeedItem item = (FeedItem) getItem(position); + if (item == null) return null; + + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.new_episodes_listitem, + parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.pubDate = (TextView) convertView + .findViewById(R.id.txtvPublished); + holder.statusUnread = convertView.findViewById(R.id.statusUnread); + holder.butSecondary = (ImageButton) convertView + .findViewById(R.id.butSecondaryAction); + holder.queueStatus = (ImageView) convertView + .findViewById(R.id.imgvInPlaylist); + holder.downloadProgress = (ProgressBar) convertView + .findViewById(R.id.pbar_download_progress); + holder.imageView = (ImageView) convertView.findViewById(R.id.imgvImage); + holder.txtvDuration = (TextView) convertView.findViewById(R.id.txtvDuration); + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + holder.title.setText(item.getTitle()); + holder.pubDate.setText(DateUtils.formatDateTime(context, item.getPubDate().getTime(), DateUtils.FORMAT_SHOW_DATE)); + if (item.isRead()) { + holder.statusUnread.setVisibility(View.GONE); + } else { + holder.statusUnread.setVisibility(View.VISIBLE); + } + + FeedMedia media = item.getMedia(); + if (media != null) { + final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media); + + if (media.getDuration() > 0) { + holder.txtvDuration.setText(Converter.getDurationStringLong(media.getDuration())); + } else { + holder.txtvDuration.setText(""); + } + + if (isDownloadingMedia) { + holder.downloadProgress.setVisibility(View.VISIBLE); + holder.txtvDuration.setVisibility(View.GONE); + } else { + holder.txtvDuration.setVisibility(View.VISIBLE); + holder.downloadProgress.setVisibility(View.GONE); + } + + if (!media.isDownloaded()) { + if (isDownloadingMedia) { + // item is being downloaded + holder.downloadProgress.setProgress(itemAccess.getItemDownloadProgressPercent(item)); + } + } + } + if (itemAccess.isInQueue(item)) { + holder.queueStatus.setVisibility(View.VISIBLE); + } else { + holder.queueStatus.setVisibility(View.INVISIBLE); + } + + actionButtonUtils.configureActionButton(holder.butSecondary, item); + holder.butSecondary.setFocusable(false); + holder.butSecondary.setTag(item); + holder.butSecondary.setOnClickListener(secondaryActionListener); + + PicassoProvider.getMediaMetadataPicassoInstance(context) + .load(item.getImageUri()) + .fit() + .into(holder.imageView); + + return convertView; + } + + private View.OnClickListener secondaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + FeedItem item = (FeedItem) v.getTag(); + actionButtonCallback.onActionButtonPressed(item); + } + }; + + + static class Holder { + TextView title; + TextView pubDate; + View statusUnread; + ImageView queueStatus; + ImageView imageView; + ProgressBar downloadProgress; + TextView txtvDuration; + ImageButton butSecondary; + } + + public interface ItemAccess { + + int getCount(); + + FeedItem getItem(int position); + + int getItemDownloadProgressPercent(FeedItem item); + + boolean isInQueue(FeedItem item); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java new file mode 100644 index 000000000..ebe519592 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java @@ -0,0 +1,127 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.*; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.storage.DownloadRequester; + +/** + * List adapter for the queue. + */ +public class QueueListAdapter extends BaseAdapter { + + + private final Context context; + private final ItemAccess itemAccess; + private final ActionButtonCallback actionButtonCallback; + private final ActionButtonUtils actionButtonUtils; + + + public QueueListAdapter(Context context, ItemAccess itemAccess, ActionButtonCallback actionButtonCallback) { + super(); + this.context = context; + this.itemAccess = itemAccess; + this.actionButtonUtils = new ActionButtonUtils(context); + this.actionButtonCallback = actionButtonCallback; + } + + @Override + public int getCount() { + return itemAccess.getCount(); + } + + @Override + public Object getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + final FeedItem item = (FeedItem) getItem(position); + if (item == null) return null; + + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.queue_listitem, + parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.butSecondary = (ImageButton) convertView + .findViewById(R.id.butSecondaryAction); + holder.position = (TextView) convertView.findViewById(R.id.txtvPosition); + holder.progress = (ProgressBar) convertView + .findViewById(R.id.pbar_download_progress); + holder.imageView = (ImageView) convertView.findViewById(R.id.imgvImage); + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + holder.title.setText(item.getTitle()); + + AdapterUtils.updateEpisodePlaybackProgress(item, context.getResources(), holder.position, holder.progress); + + FeedMedia media = item.getMedia(); + if (media != null) { + final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media); + + if (!media.isDownloaded()) { + if (isDownloadingMedia) { + // item is being downloaded + holder.progress.setVisibility(View.VISIBLE); + holder.progress.setProgress(itemAccess.getItemDownloadProgressPercent(item)); + } + } + } + + actionButtonUtils.configureActionButton(holder.butSecondary, item); + holder.butSecondary.setFocusable(false); + holder.butSecondary.setTag(item); + holder.butSecondary.setOnClickListener(secondaryActionListener); + + PicassoProvider.getMediaMetadataPicassoInstance(context) + .load(item.getImageUri()) + .fit() + .into(holder.imageView); + + return convertView; + } + + private View.OnClickListener secondaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + FeedItem item = (FeedItem) v.getTag(); + actionButtonCallback.onActionButtonPressed(item); + } + }; + + + static class Holder { + TextView title; + ImageView imageView; + TextView position; + ProgressBar progress; + ImageButton butSecondary; + } + + public interface ItemAccess { + int getCount(); + + FeedItem getItem(int position); + + int getItemDownloadProgressPercent(FeedItem item); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java new file mode 100644 index 000000000..2314c2269 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java @@ -0,0 +1,110 @@ +package de.danoeh.antennapod.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedComponent; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.SearchResult; + +/** + * List adapter for search activity. + */ +public class SearchlistAdapter extends BaseAdapter { + + private final Context context; + private final ItemAccess itemAccess; + + + public SearchlistAdapter(Context context, ItemAccess itemAccess) { + this.context = context; + this.itemAccess = itemAccess; + } + + @Override + public int getCount() { + return itemAccess.getCount(); + } + + @Override + public SearchResult getItem(int position) { + return itemAccess.getItem(position); + } + + @Override + public long getItemId(int position) { + return 0; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final Holder holder; + SearchResult result = getItem(position); + FeedComponent component = result.getComponent(); + + // Inflate Layout + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + convertView = inflater.inflate(R.layout.searchlist_item, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.cover = (ImageView) convertView + .findViewById(R.id.imgvFeedimage); + holder.subtitle = (TextView) convertView + .findViewById(R.id.txtvSubtitle); + + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + if (component.getClass() == Feed.class) { + final Feed feed = (Feed) component; + holder.title.setText(feed.getTitle()); + holder.subtitle.setVisibility(View.GONE); + + PicassoProvider.getDefaultPicassoInstance(context) + .load(feed.getImageUri()) + .fit() + .into(holder.cover); + + } else if (component.getClass() == FeedItem.class) { + final FeedItem item = (FeedItem) component; + holder.title.setText(item.getTitle()); + if (result.getSubtitle() != null) { + holder.subtitle.setVisibility(View.VISIBLE); + holder.subtitle.setText(result.getSubtitle()); + } + + PicassoProvider.getDefaultPicassoInstance(context) + .load(item.getFeed().getImageUri()) + .fit() + .into(holder.cover); + + } + + return convertView; + } + + static class Holder { + ImageView cover; + TextView title; + TextView subtitle; + } + + public static interface ItemAccess { + int getCount(); + + SearchResult getItem(int position); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java new file mode 100644 index 000000000..f2e78a57e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java @@ -0,0 +1,64 @@ +package de.danoeh.antennapod.adapter.gpodnet; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.List; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; + +/** + * Adapter for displaying a list of GPodnetPodcast-Objects. + */ +public class PodcastListAdapter extends ArrayAdapter<GpodnetPodcast> { + + public PodcastListAdapter(Context context, int resource, List<GpodnetPodcast> objects) { + super(context, resource, objects); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Holder holder; + + GpodnetPodcast podcast = getItem(position); + + // Inflate Layout + if (convertView == null) { + holder = new Holder(); + LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + convertView = inflater.inflate(R.layout.gpodnet_podcast_listitem, parent, false); + holder.title = (TextView) convertView.findViewById(R.id.txtvTitle); + holder.description = (TextView) convertView.findViewById(R.id.txtvDescription); + holder.image = (ImageView) convertView.findViewById(R.id.imgvCover); + + convertView.setTag(holder); + } else { + holder = (Holder) convertView.getTag(); + } + + holder.title.setText(podcast.getTitle()); + holder.description.setText(podcast.getDescription()); + + PicassoProvider.getDefaultPicassoInstance(convertView.getContext()) + .load(podcast.getLogoUrl()) + .fit() + .into(holder.image); + + return convertView; + } + + static class Holder { + TextView title; + TextView description; + ImageView image; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java b/app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java new file mode 100644 index 000000000..21ae5291e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java @@ -0,0 +1,177 @@ +package de.danoeh.antennapod.asynctask; + +import android.app.Activity; +import android.content.*; +import android.os.Handler; +import android.os.IBinder; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.service.download.DownloadService; +import de.danoeh.antennapod.service.download.Downloader; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Provides access to the DownloadService's list of items that are currently being downloaded. + * The DownloadObserver object should be created in the activity's onCreate() method. resume() and pause() + * should be called in the activity's onResume() and onPause() methods + */ +public class DownloadObserver { + private static final String TAG = "DownloadObserver"; + + /** + * Time period between update notifications. + */ + public static final int WAITING_INTERVAL_MS = 3000; + + private volatile Activity activity; + private final Handler handler; + private final Callback callback; + + private DownloadService downloadService = null; + private AtomicBoolean mIsBound = new AtomicBoolean(false); + + private Thread refresherThread; + private AtomicBoolean refresherThreadRunning = new AtomicBoolean(false); + + + /** + * Creates a new download observer. + * + * @param activity Used for registering receivers + * @param handler All callback methods are executed on this handler. The handler MUST run on the GUI thread. + * @param callback Callback methods for posting content updates + * @throws java.lang.IllegalArgumentException if one of the arguments is null. + */ + public DownloadObserver(Activity activity, Handler handler, Callback callback) { + Validate.notNull(activity); + Validate.notNull(handler); + Validate.notNull(callback); + + this.activity = activity; + this.handler = handler; + this.callback = callback; + } + + public void onResume() { + if (BuildConfig.DEBUG) Log.d(TAG, "DownloadObserver resumed"); + activity.registerReceiver(contentChangedReceiver, new IntentFilter(DownloadService.ACTION_DOWNLOADS_CONTENT_CHANGED)); + connectToDownloadService(); + } + + public void onPause() { + if (BuildConfig.DEBUG) Log.d(TAG, "DownloadObserver paused"); + try { + activity.unregisterReceiver(contentChangedReceiver); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + try { + activity.unbindService(mConnection); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + stopRefresher(); + } + + private BroadcastReceiver contentChangedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + // reconnect to DownloadService if connection has been closed + if (downloadService == null) { + connectToDownloadService(); + } + callback.onContentChanged(); + startRefresher(); + } + }; + + public interface Callback { + void onContentChanged(); + + void onDownloadDataAvailable(List<Downloader> downloaderList); + } + + private void connectToDownloadService() { + activity.bindService(new Intent(activity, DownloadService.class), mConnection, 0); + } + + private ServiceConnection mConnection = new ServiceConnection() { + public void onServiceDisconnected(ComponentName className) { + downloadService = null; + mIsBound.set(false); + stopRefresher(); + Log.i(TAG, "Closed connection with DownloadService."); + } + + public void onServiceConnected(ComponentName name, IBinder service) { + downloadService = ((DownloadService.LocalBinder) service) + .getService(); + mIsBound.set(true); + if (BuildConfig.DEBUG) + Log.d(TAG, "Connection to service established"); + List<Downloader> downloaderList = downloadService.getDownloads(); + if (downloaderList != null && !downloaderList.isEmpty()) { + callback.onDownloadDataAvailable(downloaderList); + startRefresher(); + } + } + }; + + private void stopRefresher() { + if (refresherThread != null) { + refresherThread.interrupt(); + } + } + + private void startRefresher() { + if (refresherThread == null || refresherThread.isInterrupted()) { + refresherThread = new Thread(new RefresherThread()); + refresherThread.start(); + } + } + + private class RefresherThread implements Runnable { + + public void run() { + refresherThreadRunning.set(true); + while (!Thread.interrupted()) { + try { + Thread.sleep(WAITING_INTERVAL_MS); + } catch (InterruptedException e) { + Log.d(TAG, "Refresher thread was interrupted"); + } + if (mIsBound.get()) { + postUpdate(); + } + } + refresherThreadRunning.set(false); + } + + private void postUpdate() { + handler.post(new Runnable() { + @Override + public void run() { + callback.onContentChanged(); + if (downloadService != null) { + List<Downloader> downloaderList = downloadService.getDownloads(); + if (downloaderList == null || downloaderList.isEmpty()) { + Thread.currentThread().interrupt(); + } + } + } + }); + } + } + + public void setActivity(Activity activity) { + Validate.notNull(activity); + this.activity = activity; + } + +} + diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java new file mode 100644 index 000000000..0549a4255 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java @@ -0,0 +1,74 @@ +package de.danoeh.antennapod.asynctask; + +import android.annotation.SuppressLint; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.os.AsyncTask; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.storage.DBWriter; + +import java.util.concurrent.ExecutionException; + +/** Removes a feed in the background. */ +public class FeedRemover extends AsyncTask<Void, Void, Void> { + Context context; + ProgressDialog dialog; + Feed feed; + + public FeedRemover(Context context, Feed feed) { + super(); + this.context = context; + this.feed = feed; + } + + @Override + protected Void doInBackground(Void... params) { + try { + DBWriter.deleteFeed(context, feed.getId()).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + return null; + } + + @Override + protected void onCancelled() { + dialog.dismiss(); + } + + @Override + protected void onPostExecute(Void result) { + dialog.dismiss(); + } + + @Override + protected void onPreExecute() { + dialog = new ProgressDialog(context); + dialog.setMessage(context.getString(R.string.feed_remover_msg)); + dialog.setOnCancelListener(new OnCancelListener() { + + @Override + public void onCancel(DialogInterface dialog) { + cancel(true); + + } + + }); + dialog.show(); + } + + @SuppressLint("NewApi") + public void executeAsync() { + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + execute(); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java new file mode 100644 index 000000000..9210ac1d1 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java @@ -0,0 +1,238 @@ +package de.danoeh.antennapod.asynctask; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.widget.Toast; + +import org.apache.commons.lang3.Validate; +import org.shredzone.flattr4j.exception.FlattrException; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.FlattrAuthActivity; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.NetworkUtils; +import de.danoeh.antennapod.util.flattr.FlattrThing; +import de.danoeh.antennapod.util.flattr.FlattrUtils; + +/** + * Performs a click action in a background thread. + * <p/> + * When started, the flattr click worker will try to flattr every item that is in the flattr queue. If no network + * connection is available it will shut down immediately. The FlattrClickWorker can also be given one additional + * FlattrThing which will be flattrd immediately. + * <p/> + * The FlattrClickWorker will display a toast notification for every item that has been flattrd. If the FlattrClickWorker failed + * to flattr something, a notification will be displayed. + */ +public class FlattrClickWorker extends AsyncTask<Void, Integer, FlattrClickWorker.ExitCode> { + protected static final String TAG = "FlattrClickWorker"; + + private static final int NOTIFICATION_ID = 4; + + private final Context context; + + public static enum ExitCode {EXIT_NORMAL, NO_TOKEN, NO_NETWORK, NO_THINGS} + + private volatile int countFailed = 0; + private volatile int countSuccess = 0; + + private volatile FlattrThing extraFlattrThing; + + /** + * Only relevant if just one thing is flattrd + */ + private volatile FlattrException exception; + + /** + * Creates a new FlattrClickWorker which will only flattr all things in the queue. + * <p/> + * The FlattrClickWorker has to be started by calling executeAsync(). + * + * @param context A context for accessing the database and posting notifications. Must not be null. + */ + public FlattrClickWorker(Context context) { + Validate.notNull(context); + this.context = context.getApplicationContext(); + } + + /** + * Creates a new FlattrClickWorker which will flattr all things in the queue and one additional + * FlattrThing. + * <p/> + * The FlattrClickWorker has to be started by calling executeAsync(). + * + * @param context A context for accessing the database and posting notifications. Must not be null. + * @param extraFlattrThing The additional thing to flattr + */ + public FlattrClickWorker(Context context, FlattrThing extraFlattrThing) { + this(context); + this.extraFlattrThing = extraFlattrThing; + } + + + @Override + protected ExitCode doInBackground(Void... params) { + + if (!FlattrUtils.hasToken()) { + return ExitCode.NO_TOKEN; + } + + if (!NetworkUtils.networkAvailable(context)) { + return ExitCode.NO_NETWORK; + } + + final List<FlattrThing> flattrQueue = DBReader.getFlattrQueue(context); + if (extraFlattrThing != null) { + flattrQueue.add(extraFlattrThing); + } else if (flattrQueue.size() == 1) { + // if only one item is flattrd, the report can specifically mentioned that this item has failed + extraFlattrThing = flattrQueue.get(0); + } + + if (flattrQueue.isEmpty()) { + return ExitCode.NO_THINGS; + } + + List<Future> dbFutures = new LinkedList<Future>(); + for (FlattrThing thing : flattrQueue) { + if (BuildConfig.DEBUG) Log.d(TAG, "Processing " + thing.getTitle()); + + try { + thing.getFlattrStatus().setUnflattred(); // pop from queue to prevent unflattrable things from getting stuck in flattr queue infinitely + FlattrUtils.clickUrl(context, thing.getPaymentLink()); + thing.getFlattrStatus().setFlattred(); + publishProgress(R.string.flattr_click_success); + countSuccess++; + + } catch (FlattrException e) { + e.printStackTrace(); + countFailed++; + if (countFailed == 1) { + exception = e; + } + } + + Future<?> f = DBWriter.setFlattredStatus(context, thing, false); + if (f != null) { + dbFutures.add(f); + } + } + + for (Future f : dbFutures) { + try { + f.get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + return ExitCode.EXIT_NORMAL; + } + + @Override + protected void onPostExecute(ExitCode exitCode) { + super.onPostExecute(exitCode); + switch (exitCode) { + case EXIT_NORMAL: + if (countFailed > 0) { + postFlattrFailedNotification(); + } + break; + case NO_NETWORK: + postToastNotification(R.string.flattr_click_enqueued); + break; + case NO_TOKEN: + postNoTokenNotification(); + break; + case NO_THINGS: // nothing to notify here + break; + } + } + + @Override + protected void onProgressUpdate(Integer... values) { + super.onProgressUpdate(values); + postToastNotification(values[0]); + } + + private void postToastNotification(int msg) { + Toast.makeText(context, context.getString(msg), Toast.LENGTH_LONG).show(); + } + + private void postNoTokenNotification() { + PendingIntent contentIntent = PendingIntent.getActivity(context, 0, new Intent(context, FlattrAuthActivity.class), 0); + + Notification notification = new NotificationCompat.Builder(context) + .setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(R.string.no_flattr_token_notification_msg))) + .setContentIntent(contentIntent) + .setContentTitle(context.getString(R.string.no_flattr_token_title)) + .setTicker(context.getString(R.string.no_flattr_token_title)) + .setSmallIcon(R.drawable.stat_notify_sync_error) + .setOngoing(false) + .setAutoCancel(true) + .build(); + ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, notification); + } + + private void postFlattrFailedNotification() { + if (countFailed == 0) { + return; + } + + PendingIntent contentIntent = PendingIntent.getActivity(context, 0, new Intent(context, MainActivity.class), 0); + String title; + String subtext; + + if (countFailed == 1) { + title = context.getString(R.string.flattrd_failed_label); + String exceptionMsg = (exception.getMessage() != null) ? exception.getMessage() : ""; + subtext = context.getString(R.string.flattr_click_failure, extraFlattrThing.getTitle()) + + "\n" + exceptionMsg; + } else { + title = context.getString(R.string.flattrd_label); + subtext = context.getString(R.string.flattr_click_success_count, countSuccess) + "\n" + + context.getString(R.string.flattr_click_failure_count, countFailed); + } + + Notification notification = new NotificationCompat.Builder(context) + .setStyle(new NotificationCompat.BigTextStyle().bigText(subtext)) + .setContentIntent(contentIntent) + .setContentTitle(title) + .setTicker(title) + .setSmallIcon(R.drawable.stat_notify_sync_error) + .setOngoing(false) + .setAutoCancel(true) + .build(); + ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, notification); + } + + + /** + * Starts the FlattrClickWorker as an AsyncTask. + */ + @TargetApi(11) + public void executeAsync() { + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + execute(); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java new file mode 100644 index 000000000..04d349671 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java @@ -0,0 +1,47 @@ +package de.danoeh.antennapod.asynctask; + +import android.content.Context; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.flattr.FlattrUtils; +import org.shredzone.flattr4j.exception.FlattrException; +import org.shredzone.flattr4j.model.Flattr; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +/** + * Fetch list of flattred things and flattr status in database in a background thread. + */ + +public class FlattrStatusFetcher extends Thread { + protected static final String TAG = "FlattrStatusFetcher"; + protected Context context; + + public FlattrStatusFetcher(Context context) { + super(); + this.context = context; + } + + @Override + public void run() { + if (BuildConfig.DEBUG) Log.d(TAG, "Starting background work: Retrieving Flattr status"); + + Thread.currentThread().setPriority(Thread.MIN_PRIORITY); + + try { + List<Flattr> flattredThings = FlattrUtils.retrieveFlattredThings(); + DBWriter.setFlattredStatus(context, flattredThings).get(); + } catch (FlattrException e) { + e.printStackTrace(); + Log.d(TAG, "flattrQueue exception retrieving list with flattred items " + e.getMessage()); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + + if (BuildConfig.DEBUG) Log.d(TAG, "Finished background work: Retrieved Flattr status"); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java new file mode 100644 index 000000000..0dcf832f7 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java @@ -0,0 +1,95 @@ +package de.danoeh.antennapod.asynctask; + + +import android.annotation.SuppressLint; +import android.app.ProgressDialog; +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.FlattrAuthActivity; +import de.danoeh.antennapod.util.flattr.FlattrUtils; +import org.shredzone.flattr4j.exception.FlattrException; +import org.shredzone.flattr4j.oauth.AccessToken; +import org.shredzone.flattr4j.oauth.AndroidAuthenticator; + +/** Fetches the access token in the background in order to avoid networkOnMainThread exception. */ + +public class FlattrTokenFetcher extends AsyncTask<Void, Void, AccessToken> { + private static final String TAG = "FlattrTokenFetcher"; + Context context; + AndroidAuthenticator auth; + AccessToken token; + Uri uri; + ProgressDialog dialog; + FlattrException exception; + + public FlattrTokenFetcher(Context context, AndroidAuthenticator auth, Uri uri) { + super(); + this.context = context; + this.auth = auth; + this.uri = uri; + } + + @Override + protected void onPostExecute(AccessToken result) { + if (result != null) { + FlattrUtils.storeToken(result); + } + dialog.dismiss(); + if (exception == null) { + FlattrAuthActivity instance = FlattrAuthActivity.getInstance(); + if (instance != null) { + instance.handleAuthenticationSuccess(); + } else { + Log.e(TAG, "FlattrAuthActivity instance was null"); + } + } else { + FlattrUtils.showErrorDialog(context, exception.getMessage()); + } + } + + + + @Override + protected void onPreExecute() { + super.onPreExecute(); + dialog = new ProgressDialog(context); + dialog.setMessage(context.getString(R.string.processing_label)); + dialog.setIndeterminate(true); + dialog.setCancelable(false); + dialog.show(); + } + + + + @Override + protected AccessToken doInBackground(Void... params) { + try { + token = auth.fetchAccessToken(uri); + } catch (FlattrException e) { + e.printStackTrace(); + exception = e; + return null; + } + if (token != null) { + if (BuildConfig.DEBUG) Log.d(TAG, "Successfully got token"); + return token; + } else { + Log.w(TAG, "Flattr token was null"); + return null; + } + } + + @SuppressLint("NewApi") + public void executeAsync() { + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + execute(); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java new file mode 100644 index 000000000..4abb1a67d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java @@ -0,0 +1,114 @@ +package de.danoeh.antennapod.asynctask; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.util.Log; +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.opml.OpmlWriter; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.LangUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; + +/** Writes an OPML file into the export directory in the background. */ +public class OpmlExportWorker extends AsyncTask<Void, Void, Void> { + private static final String TAG = "OpmlExportWorker"; + private static final String DEFAULT_OUTPUT_NAME = "antennapod-feeds.opml"; + private Context context; + private File output; + + private ProgressDialog progDialog; + private Exception exception; + + public OpmlExportWorker(Context context, File output) { + this.context = context; + this.output = output; + } + + public OpmlExportWorker(Context context) { + this.context = context; + } + + @Override + protected Void doInBackground(Void... params) { + OpmlWriter opmlWriter = new OpmlWriter(); + if (output == null) { + output = new File( + UserPreferences.getDataFolder(context, PodcastApp.EXPORT_DIR), + DEFAULT_OUTPUT_NAME); + if (output.exists()) { + Log.w(TAG, "Overwriting previously exported file."); + output.delete(); + } + } + OutputStreamWriter writer = null; + try { + writer = new OutputStreamWriter(new FileOutputStream(output), LangUtils.UTF_8); + opmlWriter.writeDocument(DBReader.getFeedList(context), writer); + } catch (IOException e) { + e.printStackTrace(); + exception = e; + } finally { + if (writer != null) { + try { + writer.close(); + } catch (IOException ioe) { + exception = ioe; + } + } + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + progDialog.dismiss(); + AlertDialog.Builder alert = new AlertDialog.Builder(context) + .setNeutralButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, + int which) { + dialog.dismiss(); + } + }); + if (exception != null) { + alert.setTitle(R.string.export_error_label); + alert.setMessage(exception.getMessage()); + } else { + alert.setTitle(R.string.opml_export_success_title); + alert.setMessage(context + .getString(R.string.opml_export_success_sum) + + output.toString()); + } + alert.create().show(); + } + + @Override + protected void onPreExecute() { + progDialog = new ProgressDialog(context); + progDialog.setMessage(context.getString(R.string.exporting_label)); + progDialog.setIndeterminate(true); + progDialog.show(); + } + + @SuppressLint("NewApi") + public void executeAsync() { + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + executeOnExecutor(THREAD_POOL_EXECUTOR); + } else { + execute(); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java new file mode 100644 index 000000000..038b8dcc5 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.asynctask; + +import android.annotation.SuppressLint; +import android.app.ProgressDialog; +import android.content.Context; +import android.os.AsyncTask; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.OpmlImportHolder; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.opml.OpmlElement; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; + +import java.util.Arrays; +import java.util.Date; + +/** Queues items for download in the background. */ +public class OpmlFeedQueuer extends AsyncTask<Void, Void, Void> { + private Context context; + private ProgressDialog progDialog; + private int[] selection; + + public OpmlFeedQueuer(Context context, int[] selection) { + super(); + this.context = context; + this.selection = Arrays.copyOf(selection, selection.length); + } + + @Override + protected void onPostExecute(Void result) { + progDialog.dismiss(); + } + + @Override + protected void onPreExecute() { + progDialog = new ProgressDialog(context); + progDialog.setMessage(context.getString(R.string.processing_label)); + progDialog.setCancelable(false); + progDialog.setIndeterminate(true); + progDialog.show(); + } + + @Override + protected Void doInBackground(Void... params) { + DownloadRequester requester = DownloadRequester.getInstance(); + for (int idx = 0; idx < selection.length; idx++) { + OpmlElement element = OpmlImportHolder.getReadElements().get( + selection[idx]); + Feed feed = new Feed(element.getXmlUrl(), new Date(), + element.getText()); + try { + requester.downloadFeed(context.getApplicationContext(), feed); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + } + return null; + } + + @SuppressLint("NewApi") + public void executeAsync() { + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + executeOnExecutor(THREAD_POOL_EXECUTOR); + } else { + execute(); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java new file mode 100644 index 000000000..13534fa64 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java @@ -0,0 +1,116 @@ +package de.danoeh.antennapod.asynctask; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.os.AsyncTask; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.opml.OpmlElement; +import de.danoeh.antennapod.opml.OpmlReader; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; + +public class OpmlImportWorker extends + AsyncTask<Void, Void, ArrayList<OpmlElement>> { + private static final String TAG = "OpmlImportWorker"; + + private Context context; + private Exception exception; + + private ProgressDialog progDialog; + + private Reader mReader; + + public OpmlImportWorker(Context context, Reader reader) { + super(); + this.context = context; + this.mReader=reader; + } + + @Override + protected ArrayList<OpmlElement> doInBackground(Void... params) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting background work"); + + if (mReader==null) { + return null; + } + + OpmlReader opmlReader = new OpmlReader(); + try { + ArrayList<OpmlElement> result = opmlReader.readDocument(mReader); + mReader.close(); + return result; + } catch (XmlPullParserException e) { + e.printStackTrace(); + exception = e; + return null; + } catch (IOException e) { + e.printStackTrace(); + exception = e; + return null; + } + + } + + @Override + protected void onPostExecute(ArrayList<OpmlElement> result) { + if (mReader != null) { + try { + mReader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + progDialog.dismiss(); + if (exception != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "An error occured while trying to parse the opml document"); + AlertDialog.Builder alert = new AlertDialog.Builder(context); + alert.setTitle(R.string.error_label); + alert.setMessage(context.getString(R.string.opml_reader_error) + + exception.getMessage()); + alert.setNeutralButton(android.R.string.ok, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + + }); + alert.create().show(); + } + } + + @Override + protected void onPreExecute() { + progDialog = new ProgressDialog(context); + progDialog.setMessage(context.getString(R.string.reading_opml_label)); + progDialog.setIndeterminate(true); + progDialog.setCancelable(false); + progDialog.show(); + } + + public boolean wasSuccessful() { + return exception != null; + } + + @SuppressLint("NewApi") + public void executeAsync() { + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + executeOnExecutor(THREAD_POOL_EXECUTOR); + } else { + execute(); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java new file mode 100644 index 000000000..26f9d9278 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java @@ -0,0 +1,37 @@ +package de.danoeh.antennapod.asynctask; + +import android.net.Uri; + +/** + * Classes that implement this interface provide access to an image resource that can + * be loaded by the Picasso library. + */ +public interface PicassoImageResource { + + /** + * This scheme should be used by PicassoImageResources to + * indicate that the image Uri points to a file that is not an image + * (e.g. a media file). This workaround is needed so that the Picasso library + * loads these Uri with a Downloader instead of trying to load it directly. + * <p/> + * For example implementations, see FeedMedia or ExternalMedia. + */ + public static final String SCHEME_MEDIA = "media"; + + + /** + * Parameter key for an encoded fallback Uri. This Uri MUST point to a local image file + */ + public static final String PARAM_FALLBACK = "fallback"; + + /** + * Returns a Uri to the image or null if no image is available. + * <p/> + * The Uri can either be an HTTP-URL, a URL pointing to a local image file or + * a non-image file (see SCHEME_MEDIA for more details). + * <p/> + * The Uri can also have an optional fallback-URL if loading the default URL + * failed (see PARAM_FALLBACK). + */ + public Uri getImageUri(); +} diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java new file mode 100644 index 000000000..849725630 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java @@ -0,0 +1,152 @@ +package de.danoeh.antennapod.asynctask; + +import android.content.Context; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import com.squareup.picasso.Cache; +import com.squareup.picasso.Downloader; +import com.squareup.picasso.LruCache; +import com.squareup.picasso.OkHttpDownloader; +import com.squareup.picasso.Picasso; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Provides access to Picasso instances. + */ +public class PicassoProvider { + private static final String TAG = "PicassoProvider"; + + private static final boolean DEBUG = false; + + private static ExecutorService executorService; + private static Cache memoryCache; + + private static Picasso defaultPicassoInstance; + private static Picasso mediaMetadataPicassoInstance; + + private static synchronized ExecutorService getExecutorService() { + if (executorService == null) { + executorService = Executors.newFixedThreadPool(3); + } + return executorService; + } + + private static synchronized Cache getMemoryCache(Context context) { + if (memoryCache == null) { + memoryCache = new LruCache(context); + } + return memoryCache; + } + + /** + * Returns a Picasso instance that uses an OkHttpDownloader. This instance can only load images + * from image files. + * <p/> + * This instance should be used as long as no images from media files are loaded. + */ + public static synchronized Picasso getDefaultPicassoInstance(Context context) { + Validate.notNull(context); + if (defaultPicassoInstance == null) { + defaultPicassoInstance = new Picasso.Builder(context) + .indicatorsEnabled(DEBUG) + .loggingEnabled(DEBUG) + .downloader(new OkHttpDownloader(context)) + .executor(getExecutorService()) + .memoryCache(getMemoryCache(context)) + .listener(new Picasso.Listener() { + @Override + public void onImageLoadFailed(Picasso picasso, Uri uri, Exception e) { + Log.e(TAG, "Failed to load Uri:" + uri.toString()); + e.printStackTrace(); + } + }) + .build(); + } + return defaultPicassoInstance; + } + + /** + * Returns a Picasso instance that uses a MediaMetadataRetriever if the given Uri is a media file + * and a default OkHttpDownloader otherwise. + */ + public static synchronized Picasso getMediaMetadataPicassoInstance(Context context) { + Validate.notNull(context); + if (mediaMetadataPicassoInstance == null) { + mediaMetadataPicassoInstance = new Picasso.Builder(context) + .indicatorsEnabled(DEBUG) + .loggingEnabled(DEBUG) + .downloader(new MediaMetadataDownloader(context)) + .executor(getExecutorService()) + .memoryCache(getMemoryCache(context)) + .listener(new Picasso.Listener() { + @Override + public void onImageLoadFailed(Picasso picasso, Uri uri, Exception e) { + Log.e(TAG, "Failed to load Uri:" + uri.toString()); + e.printStackTrace(); + } + }) + .build(); + } + return mediaMetadataPicassoInstance; + } + + private static class MediaMetadataDownloader implements Downloader { + + private static final String TAG = "MediaMetadataDownloader"; + + private final OkHttpDownloader okHttpDownloader; + + public MediaMetadataDownloader(Context context) { + Validate.notNull(context); + okHttpDownloader = new OkHttpDownloader(context); + } + + @Override + public Response load(Uri uri, boolean b) throws IOException { + if (StringUtils.equals(uri.getScheme(), PicassoImageResource.SCHEME_MEDIA)) { + String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(FilenameUtils.getExtension(uri.getLastPathSegment())); + if (StringUtils.startsWith(type, "image")) { + File imageFile = new File(uri.toString()); + return new Response(new BufferedInputStream(new FileInputStream(imageFile)), true, imageFile.length()); + } else { + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + mmr.setDataSource(uri.getPath()); + byte[] data = mmr.getEmbeddedPicture(); + mmr.release(); + + if (data != null) { + return new Response(new ByteArrayInputStream(data), true, data.length); + } else { + + // check for fallback Uri + String fallbackParam = uri.getQueryParameter(PicassoImageResource.PARAM_FALLBACK); + + if (fallbackParam != null) { + String fallback = Uri.decode(Uri.parse(fallbackParam).getPath()); + if (fallback != null) { + File imageFile = new File(fallback); + return new Response(new BufferedInputStream(new FileInputStream(imageFile)), true, imageFile.length()); + } + } + return null; + } + } + } + return okHttpDownloader.load(uri, b); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java b/app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java new file mode 100644 index 000000000..56d1ca092 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java @@ -0,0 +1,212 @@ +package de.danoeh.antennapod.backup; + +import android.app.backup.BackupAgentHelper; +import android.app.backup.BackupDataInputStream; +import android.app.backup.BackupDataOutput; +import android.app.backup.BackupHelper; +import android.content.Context; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import de.danoeh.antennapod.BuildConfig; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.math.BigInteger; +import java.security.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; + +import de.danoeh.antennapod.AppConfig; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.opml.OpmlElement; +import de.danoeh.antennapod.opml.OpmlReader; +import de.danoeh.antennapod.opml.OpmlWriter; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.LangUtils; + +public class OpmlBackupAgent extends BackupAgentHelper { + private static final String OPML_BACKUP_KEY = "opml"; + + @Override + public void onCreate() { + addHelper(OPML_BACKUP_KEY, new OpmlBackupHelper(this)); + } + + private static final void LOGD(String tag, String msg) { + if (BuildConfig.DEBUG && Log.isLoggable(tag, Log.DEBUG)) { + Log.d(tag, msg); + } + } + + private static final void LOGD(String tag, String msg, Throwable tr) { + if (BuildConfig.DEBUG && Log.isLoggable(tag, Log.DEBUG)) { + Log.d(tag, msg, tr); + } + } + + /** Class for backing up and restoring the OPML file. */ + private static class OpmlBackupHelper implements BackupHelper { + private static final String TAG = "OpmlBackupHelper"; + + private static final String OPML_ENTITY_KEY = "antennapod-feeds.opml"; + + private final Context mContext; + + /** Checksum of restored OPML file */ + private byte[] mChecksum; + + public OpmlBackupHelper(Context context) { + mContext = context; + } + + @Override + public void performBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) { + Log.d(TAG, "Performing backup"); + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + MessageDigest digester = null; + Writer writer; + + try { + digester = MessageDigest.getInstance("MD5"); + writer = new OutputStreamWriter(new DigestOutputStream(byteStream, digester), + LangUtils.UTF_8); + } catch (NoSuchAlgorithmException e) { + writer = new OutputStreamWriter(byteStream, LangUtils.UTF_8); + } + + try { + // Write OPML + new OpmlWriter().writeDocument(DBReader.getFeedList(mContext), writer); + + // Compare checksum of new and old file to see if we need to perform a backup at all + if (digester != null) { + byte[] newChecksum = digester.digest(); + LOGD(TAG, "New checksum: " + new BigInteger(1, newChecksum).toString(16)); + + // Get the old checksum + if (oldState != null) { + FileInputStream inState = new FileInputStream(oldState.getFileDescriptor()); + int len = inState.read(); + + if (len != -1) { + byte[] oldChecksum = new byte[len]; + inState.read(oldChecksum); + LOGD(TAG, "Old checksum: " + new BigInteger(1, oldChecksum).toString(16)); + + if (Arrays.equals(oldChecksum, newChecksum)) { + LOGD(TAG, "Checksums are the same; won't backup"); + return; + } + } + } + + writeNewStateDescription(newState, newChecksum); + } + + LOGD(TAG, "Backing up OPML"); + byte[] bytes = byteStream.toByteArray(); + data.writeEntityHeader(OPML_ENTITY_KEY, bytes.length); + data.writeEntityData(bytes, bytes.length); + } catch (IOException e) { + Log.e(TAG, "Error during backup", e); + } finally { + if (writer != null) { + try { + writer.close(); + } catch (IOException e) { + } + } + } + } + + @Override + public void restoreEntity(BackupDataInputStream data) { + LOGD(TAG, "Backup restore"); + + if (!OPML_ENTITY_KEY.equals(data.getKey())) { + LOGD(TAG, "Unknown entity key: " + data.getKey()); + return; + } + + MessageDigest digester = null; + Reader reader; + + try { + digester = MessageDigest.getInstance("MD5"); + reader = new InputStreamReader(new DigestInputStream(data, digester), + LangUtils.UTF_8); + } catch (NoSuchAlgorithmException e) { + reader = new InputStreamReader(data, LangUtils.UTF_8); + } + + try { + ArrayList<OpmlElement> opmlElements = new OpmlReader().readDocument(reader); + mChecksum = digester == null ? null : digester.digest(); + DownloadRequester downloader = DownloadRequester.getInstance(); + Date lastUpdated = new Date(); + + for (OpmlElement opmlElem : opmlElements) { + Feed feed = new Feed(opmlElem.getXmlUrl(), lastUpdated, opmlElem.getText()); + + try { + downloader.downloadFeed(mContext, feed); + } catch (DownloadRequestException e) { + LOGD(TAG, "Error while restoring/downloading feed", e); + } + } + } catch (XmlPullParserException e) { + Log.e(TAG, "Error while parsing the OPML file", e); + } catch (IOException e) { + Log.e(TAG, "Failed to restore OPML backup", e); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + } + } + } + } + + @Override + public void writeNewStateDescription(ParcelFileDescriptor newState) { + writeNewStateDescription(newState, mChecksum); + } + + /** + * Writes the new state description, which is the checksum of the OPML file. + * + * @param newState + * @param checksum + */ + private void writeNewStateDescription(ParcelFileDescriptor newState, byte[] checksum) { + if (checksum == null) { + return; + } + + try { + FileOutputStream outState = new FileOutputStream(newState.getFileDescriptor()); + outState.write(checksum.length); + outState.write(checksum); + outState.flush(); + outState.close(); + } catch (IOException e) { + Log.e(TAG, "Failed to write new state description", e); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java new file mode 100644 index 000000000..bdb2d68ba --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java @@ -0,0 +1,89 @@ +package de.danoeh.antennapod.dialog; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import de.danoeh.antennapod.R; + +/** + * Displays a dialog with a username and password text field and an optional checkbox to save username and preferences. + */ +public abstract class AuthenticationDialog extends Dialog { + + private final int titleRes; + private final boolean enableUsernameField; + private final boolean showSaveCredentialsCheckbox; + private final String usernameInitialValue; + private final String passwordInitialValue; + + public AuthenticationDialog(Context context, int titleRes, boolean enableUsernameField, boolean showSaveCredentialsCheckbox, String usernameInitialValue, String passwordInitialValue) { + super(context); + this.titleRes = titleRes; + this.enableUsernameField = enableUsernameField; + this.showSaveCredentialsCheckbox = showSaveCredentialsCheckbox; + this.usernameInitialValue = usernameInitialValue; + this.passwordInitialValue = passwordInitialValue; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.authentication_dialog); + final EditText etxtUsername = (EditText) findViewById(R.id.etxtUsername); + final EditText etxtPassword = (EditText) findViewById(R.id.etxtPassword); + final CheckBox saveUsernamePassword = (CheckBox) findViewById(R.id.chkSaveUsernamePassword); + final Button butConfirm = (Button) findViewById(R.id.butConfirm); + final Button butCancel = (Button) findViewById(R.id.butCancel); + + if (titleRes != 0) { + setTitle(titleRes); + } else { + requestWindowFeature(Window.FEATURE_NO_TITLE); + } + etxtUsername.setEnabled(enableUsernameField); + if (showSaveCredentialsCheckbox) { + saveUsernamePassword.setVisibility(View.VISIBLE); + } else { + saveUsernamePassword.setVisibility(View.GONE); + } + if (usernameInitialValue != null) { + etxtUsername.setText(usernameInitialValue); + } + if (passwordInitialValue != null) { + etxtPassword.setText(passwordInitialValue); + } + setOnCancelListener(new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + onCancelled(); + } + }); + butCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + cancel(); + } + }); + butConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onConfirmed(etxtUsername.getText().toString(), + etxtPassword.getText().toString(), + showSaveCredentialsCheckbox && saveUsernamePassword.isChecked()); + dismiss(); + } + }); + } + + protected void onCancelled() { + + } + + protected abstract void onConfirmed(String username, String password, boolean saveUsernamePassword); +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java new file mode 100644 index 000000000..d1ed795dc --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java @@ -0,0 +1,107 @@ +package de.danoeh.antennapod.dialog; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; + +/** + * Creates a new AlertDialog that displays preferences for auto-flattring to the user. + */ +public class AutoFlattrPreferenceDialog { + + private AutoFlattrPreferenceDialog() { + } + + public static void newAutoFlattrPreferenceDialog(final Activity activity, final AutoFlattrPreferenceDialogInterface callback) { + Validate.notNull(activity); + Validate.notNull(callback); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + + @SuppressLint("InflateParams") View view = activity.getLayoutInflater().inflate(R.layout.autoflattr_preference_dialog, null); + final CheckBox chkAutoFlattr = (CheckBox) view.findViewById(R.id.chkAutoFlattr); + final SeekBar skbPercent = (SeekBar) view.findViewById(R.id.skbPercent); + final TextView txtvStatus = (TextView) view.findViewById(R.id.txtvStatus); + + chkAutoFlattr.setChecked(UserPreferences.isAutoFlattr()); + skbPercent.setEnabled(chkAutoFlattr.isChecked()); + txtvStatus.setEnabled(chkAutoFlattr.isChecked()); + + final int initialValue = (int) (UserPreferences.getAutoFlattrPlayedDurationThreshold() * 100.0f); + setStatusMsgText(activity, txtvStatus, initialValue); + skbPercent.setProgress(initialValue); + + chkAutoFlattr.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + skbPercent.setEnabled(chkAutoFlattr.isChecked()); + txtvStatus.setEnabled(chkAutoFlattr.isChecked()); + } + }); + + skbPercent.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + setStatusMsgText(activity, txtvStatus, progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + + builder.setTitle(R.string.pref_auto_flattr_title) + .setView(view) + .setPositiveButton(R.string.confirm_label, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + float progDouble = ((float) skbPercent.getProgress()) / 100.0f; + callback.onConfirmed(chkAutoFlattr.isChecked(), progDouble); + dialog.dismiss(); + } + }) + .setNegativeButton(R.string.cancel_label, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + callback.onCancelled(); + dialog.dismiss(); + } + }) + .setCancelable(false).show(); + } + + private static void setStatusMsgText(Context context, TextView txtvStatus, int progress) { + if (progress == 0) { + txtvStatus.setText(R.string.auto_flattr_ater_beginning); + } else if (progress == 100) { + txtvStatus.setText(R.string.auto_flattr_ater_end); + } else { + txtvStatus.setText(context.getString(R.string.auto_flattr_after_percent, progress)); + } + } + + public static interface AutoFlattrPreferenceDialogInterface { + public void onCancelled(); + + public void onConfirmed(boolean autoFlattrEnabled, float autoFlattrValue); + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java new file mode 100644 index 000000000..df71fff77 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java @@ -0,0 +1,64 @@ +package de.danoeh.antennapod.dialog; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; + +/** + * Creates an AlertDialog which asks the user to confirm something. Other + * classes can handle events like confirmation or cancellation. + */ +public abstract class ConfirmationDialog { + private static final String TAG = "ConfirmationDialog"; + + Context context; + int titleId; + int messageId; + + public ConfirmationDialog(Context context, int titleId, int messageId) { + this.context = context; + this.titleId = titleId; + this.messageId = messageId; + } + + public void onCancelButtonPressed(DialogInterface dialog) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Dialog was cancelled"); + dialog.dismiss(); + } + + public abstract void onConfirmButtonPressed(DialogInterface dialog); + + public final AlertDialog createNewDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(titleId); + builder.setMessage(messageId); + builder.setPositiveButton(R.string.confirm_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + onConfirmButtonPressed(dialog); + } + }); + builder.setNegativeButton(R.string.cancel_label, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + onCancelButtonPressed(dialog); + } + }); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + + @Override + public void onCancel(DialogInterface dialog) { + onCancelButtonPressed(dialog); + } + }); + return builder.create(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java b/app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java new file mode 100644 index 000000000..e363a6911 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java @@ -0,0 +1,30 @@ +package de.danoeh.antennapod.dialog; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import de.danoeh.antennapod.R; + +/** Creates Alert Dialogs if a DownloadRequestException has happened. */ +public class DownloadRequestErrorDialogCreator { + private DownloadRequestErrorDialogCreator() { + } + + public static void newRequestErrorDialog(Context context, + String errorMessage) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setNeutralButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setTitle(R.string.download_error_request_error) + .setMessage( + context.getString(R.string.download_request_error_dialog_message_prefix) + + errorMessage); + builder.create().show(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java new file mode 100644 index 000000000..7384463de --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java @@ -0,0 +1,428 @@ +package de.danoeh.antennapod.dialog; + +import android.annotation.TargetApi; +import android.app.Dialog; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.res.TypedArray; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.support.v7.widget.PopupMenu; +import android.util.Log; +import android.util.TypedValue; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; + +import org.apache.commons.lang3.StringEscapeUtils; +import org.apache.commons.lang3.Validate; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.adapter.DefaultActionButtonCallback; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.ShownotesProvider; +import de.danoeh.antennapod.util.menuhandler.FeedItemMenuHandler; + +/** + * Shows information about a specific FeedItem and provides actions like playing, downloading, etc. + */ +public class FeedItemDialog extends Dialog { + private static final String TAG = "FeedItemDialog"; + + private FeedItem item; + private QueueAccess queue; + + private View header; + private TextView txtvTitle; + private WebView webvDescription; + private ImageButton butAction1; + private ImageButton butAction2; + private ImageButton butMore; + private PopupMenu popupMenu; + + public static FeedItemDialog newInstance(Context context, FeedItemDialogSavedInstance savedInstance) { + Validate.notNull(savedInstance); + FeedItemDialog dialog = newInstance(context, savedInstance.item, savedInstance.queueAccess); + if (savedInstance.isShowing) { + dialog.show(); + } + return dialog; + } + + public static FeedItemDialog newInstance(Context context, FeedItem item, QueueAccess queue) { + if (useDarkThemeWorkAround()) { + return new FeedItemDialog(context, R.style.Theme_AntennaPod_Dark, item, queue); + } else { + return new FeedItemDialog(context, item, queue); + } + } + + public FeedItemDialog(Context context, int theme, FeedItem item, QueueAccess queue) { + super(context, theme); + Validate.notNull(item); + Validate.notNull(queue); + this.item = item; + this.queue = queue; + } + + private FeedItemDialog(Context context, FeedItem item, QueueAccess queue) { + this(context, 0, item, queue); + } + + /** + * Returns true if the dialog should use a dark theme. This has to be done on Gingerbread devices + * because dialogs are only available in a dark theme. + */ + private static boolean useDarkThemeWorkAround() { + return Build.VERSION.SDK_INT <= Build.VERSION_CODES.GINGERBREAD_MR1 + && UserPreferences.getTheme() != R.style.Theme_AntennaPod_Dark; + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.feeditem_dialog); + + txtvTitle = (TextView) findViewById(R.id.txtvTitle); + header = findViewById(R.id.header); + webvDescription = (WebView) findViewById(R.id.webview); + butAction1 = (ImageButton) findViewById(R.id.butAction1); + butAction2 = (ImageButton) findViewById(R.id.butAction2); + butMore = (ImageButton) findViewById(R.id.butMoreActions); + popupMenu = new PopupMenu(getContext(), butMore); + + webvDescription.setWebViewClient(new WebViewClient()); + txtvTitle.setText(item.getTitle()); + + if (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) { + if (Build.VERSION.SDK_INT >= 11 + && Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { + webvDescription.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + } + webvDescription.setBackgroundColor(getContext().getResources().getColor( + R.color.black)); + } + webvDescription.getSettings().setUseWideViewPort(false); + webvDescription.getSettings().setLayoutAlgorithm( + WebSettings.LayoutAlgorithm.NARROW_COLUMNS); + webvDescription.getSettings().setLoadWithOverviewMode(true); + webvDescription.setWebViewClient(new WebViewClient() { + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + try { + getContext().startActivity(intent); + } catch (ActivityNotFoundException e) { + e.printStackTrace(); + return false; + } + return true; + } + }); + + loadDescriptionWebview(item); + + butAction1.setOnClickListener(new View.OnClickListener() { + DefaultActionButtonCallback actionButtonCallback = new DefaultActionButtonCallback(getContext()); + + @Override + + public void onClick(View v) { + actionButtonCallback.onActionButtonPressed(item); + FeedMedia media = item.getMedia(); + if (media != null && media.isDownloaded()) { + // playback was started, dialog should close itself + dismiss(); + } + + } + } + ); + + butAction2.setOnClickListener(new View.OnClickListener() + + { + @Override + public void onClick(View v) { + if (item.hasMedia()) { + FeedMedia media = item.getMedia(); + if (!media.isDownloaded()) { + DBTasks.playMedia(getContext(), media, true, true, true); + dismiss(); + } else { + DBWriter.deleteFeedMediaOfItem(getContext(), media.getId()); + } + } else if (item.getLink() != null) { + Uri uri = Uri.parse(item.getLink()); + getContext().startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } + } + } + ); + + butMore.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + popupMenu.getMenu().clear(); + popupMenu.inflate(R.menu.feeditem_dialog); + if (item.hasMedia()) { + FeedItemMenuHandler.onPrepareMenu(popupMenuInterface, item, true, queue); + } else { + // these are already available via button1 and button2 + FeedItemMenuHandler.onPrepareMenu(popupMenuInterface, item, true, queue, + R.id.mark_read_item, R.id.visit_website_item); + } + popupMenu.show(); + } + } + ); + + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + + try { + return FeedItemMenuHandler.onMenuItemClicked(getContext(), menuItem.getItemId(), item); + } catch (DownloadRequestException e) { + e.printStackTrace(); + Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_LONG).show(); + return true; + } + } + } + ); + + updateMenuAppearance(); + } + + + private final FeedItemMenuHandler.MenuInterface popupMenuInterface = new FeedItemMenuHandler.MenuInterface() { + @Override + public void setItemVisibility(int id, boolean visible) { + MenuItem item = popupMenu.getMenu().findItem(id); + if (item != null) { + item.setVisible(visible); + } + } + }; + + public void updateMenuAppearance() { + if (item == null || queue == null) { + Log.w(TAG, "UpdateMenuAppearance called while item or queue was null"); + return; + } + FeedMedia media = item.getMedia(); + if (media == null) { + TypedArray drawables = getContext().obtainStyledAttributes(new int[]{R.attr.navigation_accept, + R.attr.location_web_site}); + + if (!item.isRead()) { + butAction1.setImageDrawable(drawables.getDrawable(0)); + butAction1.setContentDescription(getContext().getString(R.string.mark_read_label)); + butAction1.setVisibility(View.VISIBLE); + } else { + butAction1.setVisibility(View.INVISIBLE); + } + + if (item.getLink() != null) { + butAction2.setImageDrawable(drawables.getDrawable(1)); + butAction2.setContentDescription(getContext().getString(R.string.visit_website_label)); + } else { + butAction2.setEnabled(false); + } + + drawables.recycle(); + } else { + boolean isDownloading = DownloadRequester.getInstance().isDownloadingFile(media); + TypedArray drawables = getContext().obtainStyledAttributes(new int[]{R.attr.av_play, + R.attr.av_download, R.attr.action_stream, R.attr.content_discard, R.attr.navigation_cancel}); + + if (!media.isDownloaded()) { + butAction2.setImageDrawable(drawables.getDrawable(2)); + butAction2.setContentDescription(getContext().getString(R.string.stream_label)); + } else { + butAction2.setImageDrawable(drawables.getDrawable(3)); + butAction2.setContentDescription(getContext().getString(R.string.remove_episode_lable)); + } + + if (isDownloading) { + butAction1.setImageDrawable(drawables.getDrawable(4)); + butAction1.setContentDescription(getContext().getString(R.string.cancel_download_label)); + } else if (media.isDownloaded()) { + butAction1.setImageDrawable(drawables.getDrawable(0)); + butAction1.setContentDescription(getContext().getString(R.string.play_label)); + } else { + butAction1.setImageDrawable(drawables.getDrawable(1)); + butAction1.setContentDescription(getContext().getString(R.string.download_label)); + } + + drawables.recycle(); + } + } + + + private void loadDescriptionWebview(final ShownotesProvider shownotesProvider) { + AsyncTask<Void, Void, Void> loadTask = new AsyncTask<Void, Void, Void>() { + String data; + + + private String applyWebviewStyle(String textColor, String data) { + final String WEBVIEW_STYLE = "<html><head><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; }</style></head><body>%s</body></html>"; + final int pageMargin = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 8, getContext().getResources() + .getDisplayMetrics() + ); + return String.format(WEBVIEW_STYLE, textColor, "100%", pageMargin, + pageMargin, pageMargin, pageMargin, data); + } + + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + // /webvDescription.loadData(url, "text/html", "utf-8"); + if (FeedItemDialog.this.isShowing() && webvDescription != null) { + webvDescription.loadDataWithBaseURL(null, data, "text/html", + "utf-8", "about:blank"); + if (BuildConfig.DEBUG) + Log.d(TAG, "Webview loaded"); + } + } + + + @Override + protected Void doInBackground(Void... params) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading Webview"); + try { + Callable<String> shownotesLoadTask = shownotesProvider.loadShownotes(); + final String shownotes = shownotesLoadTask.call(); + + data = StringEscapeUtils.unescapeHtml4(shownotes); + TypedArray res = getContext() + .getTheme() + .obtainStyledAttributes( + new int[]{android.R.attr.textColorPrimary}); + int colorResource; + if (useDarkThemeWorkAround()) { + colorResource = getContext().getResources().getColor(R.color.black); + } else { + colorResource = res.getColor(0, 0); + } + String colorString = String.format("#%06X", + 0xFFFFFF & colorResource); + Log.i(TAG, "text color: " + colorString); + res.recycle(); + data = applyWebviewStyle(colorString, data); + + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + }; + loadTask.execute(); + } + + /** + * Convenience method that calls setQueue() and setItemFromCollection() with + * the given arguments. + * + * @return true if one of the calls to setItemFromCollection returned true, + * false otherwise. + */ + public boolean updateContent(QueueAccess queue, List<FeedItem>... collections) { + setQueue(queue); + + boolean setItemFromCollectionResult = false; + if (collections != null) { + for (List<FeedItem> list : collections) { + setItemFromCollectionResult |= setItemFromCollection(list); + } + } + if (isShowing()) { + updateMenuAppearance(); + } + + return setItemFromCollectionResult; + } + + + public void setItem(FeedItem item) { + Validate.notNull(item); + this.item = item; + } + + /** + * Finds the FeedItem of this dialog in a collection and updates its state from that + * collection. + * + * @return true if the FeedItem was found, false otherwise. + */ + public boolean setItemFromCollection(Collection<FeedItem> items) { + for (FeedItem item : items) { + if (item.getId() == this.item.getId()) { + setItem(item); + return true; + } + } + return false; + } + + public void setQueue(QueueAccess queue) { + Validate.notNull(queue); + this.queue = queue; + } + + public FeedItem getItem() { + return item; + } + + public QueueAccess getQueue() { + return queue; + } + + public FeedItemDialogSavedInstance save() { + return new FeedItemDialogSavedInstance(item, queue, isShowing()); + } + + /** + * Used to save the FeedItemDialog's state across configuration changes + */ + public static class FeedItemDialogSavedInstance { + final FeedItem item; + final QueueAccess queueAccess; + final boolean isShowing; + + private FeedItemDialogSavedInstance(FeedItem item, QueueAccess queueAccess, boolean isShowing) { + this.item = item; + this.queueAccess = queueAccess; + this.isShowing = isShowing; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java new file mode 100644 index 000000000..a9c596d2e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java @@ -0,0 +1,67 @@ +package de.danoeh.antennapod.dialog; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.text.Editable; +import android.text.InputType; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.LinearLayout; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.preferences.GpodnetPreferences; + +/** + * Creates a dialog that lets the user change the hostname for the gpodder.net service. + */ +public class GpodnetSetHostnameDialog { + private static final String TAG = "GpodnetSetHostnameDialog"; + + public static AlertDialog createDialog(final Context context) { + AlertDialog.Builder dialog = new AlertDialog.Builder(context); + final EditText et = new EditText(context); + et.setText(GpodnetPreferences.getHostname()); + et.setInputType(InputType.TYPE_TEXT_VARIATION_URI); + dialog.setTitle(R.string.pref_gpodnet_sethostname_title) + .setView(setupContentView(context, et)) + .setPositiveButton(R.string.confirm_label, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + final Editable e = et.getText(); + if (e != null) { + GpodnetPreferences.setHostname(e.toString()); + } + dialog.dismiss(); + } + }) + .setNegativeButton(R.string.cancel_label, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }) + .setNeutralButton(R.string.pref_gpodnet_sethostname_use_default_host, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + GpodnetPreferences.setHostname(GpodnetService.DEFAULT_BASE_HOST); + dialog.dismiss(); + } + }) + .setCancelable(true); + return dialog.show(); + } + + private static View setupContentView(Context context, EditText et) { + LinearLayout ll = new LinearLayout(context); + ll.addView(et); + LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) et.getLayoutParams(); + if (params != null) { + params.setMargins(8, 8, 8, 8); + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + } + return ll; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java new file mode 100644 index 000000000..bbd514640 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java @@ -0,0 +1,138 @@ +package de.danoeh.antennapod.dialog; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.inputmethod.InputMethodManager; +import android.widget.*; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; + +import java.util.concurrent.TimeUnit; + +public abstract class TimeDialog extends Dialog { + private static final String TAG = "TimeDialog"; + + private static final int DEFAULT_SPINNER_POSITION = 1; + + private Context context; + + private EditText etxtTime; + private Spinner spTimeUnit; + private Button butConfirm; + private Button butCancel; + + private TimeUnit[] units = {TimeUnit.SECONDS, TimeUnit.MINUTES, + TimeUnit.HOURS}; + + public TimeDialog(Context context, int titleTextId, int leftButtonTextId) { + super(context); + this.context = context; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + String[] spinnerContent = new String[]{context.getString(R.string.time_unit_seconds), + context.getString(R.string.time_unit_minutes), + context.getString(R.string.time_unit_hours)}; + + setContentView(R.layout.time_dialog); + etxtTime = (EditText) findViewById(R.id.etxtTime); + spTimeUnit = (Spinner) findViewById(R.id.spTimeUnit); + butConfirm = (Button) findViewById(R.id.butConfirm); + butCancel = (Button) findViewById(R.id.butCancel); + + butConfirm.setText(R.string.set_sleeptimer_label); + butCancel.setText(R.string.cancel_label); + setTitle(R.string.set_sleeptimer_label); + ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<String>( + this.getContext(), android.R.layout.simple_spinner_item, + spinnerContent); + spinnerAdapter + .setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spTimeUnit.setAdapter(spinnerAdapter); + spTimeUnit.setSelection(DEFAULT_SPINNER_POSITION); + butCancel.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + dismiss(); + } + }); + butConfirm.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + try { + long input = readTimeMillis(); + onTimeEntered(input); + dismiss(); + } catch (NumberFormatException e) { + e.printStackTrace(); + Toast toast = Toast.makeText(context, + R.string.time_dialog_invalid_input, + Toast.LENGTH_LONG); + toast.show(); + } + } + }); + etxtTime.addTextChangedListener(new TextWatcher() { + + @Override + public void afterTextChanged(Editable s) { + checkInputLength(s.length()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, + int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, + int count) { + + } + }); + checkInputLength(etxtTime.getText().length()); + etxtTime.postDelayed(new Runnable() { + @Override + public void run() { + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(etxtTime, InputMethodManager.SHOW_IMPLICIT); + } + }, 100); + + + + } + + private void checkInputLength(int length) { + if (length > 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Length is larger than 0, enabling confirm button"); + butConfirm.setEnabled(true); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Length is smaller than 0, disabling confirm button"); + butConfirm.setEnabled(false); + } + } + + public abstract void onTimeEntered(long millis); + + private long readTimeMillis() { + TimeUnit selectedUnit = units[spTimeUnit.getSelectedItemPosition()]; + long value = Long.valueOf(etxtTime.getText().toString()); + return selectedUnit.toMillis(value); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java new file mode 100644 index 000000000..b009e76a7 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java @@ -0,0 +1,100 @@ +package de.danoeh.antennapod.dialog; + +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; + +import java.util.Arrays; +import java.util.List; + +public class VariableSpeedDialog { + private VariableSpeedDialog() { + } + + public static void showDialog(final Context context) { + if (com.aocate.media.MediaPlayer.isPrestoLibraryInstalled(context)) { + showSpeedSelectorDialog(context); + } else { + showGetPluginDialog(context); + } + } + + private static void showGetPluginDialog(final Context context) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.no_playback_plugin_title); + builder.setMessage(R.string.no_playback_plugin_msg); + builder.setNegativeButton(R.string.close_label, null); + builder.setPositiveButton(R.string.download_plugin_label, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + Intent playStoreIntent = new Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=com.falconware.prestissimo")); + context.startActivity(playStoreIntent); + } catch (ActivityNotFoundException e) { + // this is usually thrown on an emulator if the Android market is not installed + e.printStackTrace(); + } + } + }); + builder.create().show(); + } + + private static void showSpeedSelectorDialog(final Context context) { + final String[] speedValues = context.getResources().getStringArray( + R.array.playback_speed_values); + // According to Java spec these get initialized to false on creation + final boolean[] speedChecked = new boolean[speedValues.length]; + + // Build the "isChecked" array so that multiChoice dialog is + // populated correctly + List<String> selectedSpeedList = Arrays.asList(UserPreferences + .getPlaybackSpeedArray()); + for (int i = 0; i < speedValues.length; i++) { + speedChecked[i] = selectedSpeedList.contains(speedValues[i]); + } + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.set_playback_speed_label); + builder.setMultiChoiceItems(R.array.playback_speed_values, + speedChecked, new DialogInterface.OnMultiChoiceClickListener() { + @Override + public void onClick(DialogInterface dialog, int which, + boolean isChecked) { + speedChecked[which] = isChecked; + } + + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + int choiceCount = 0; + for (int i = 0; i < speedChecked.length; i++) { + if (speedChecked[i]) { + choiceCount++; + } + } + String[] newSpeedValues = new String[choiceCount]; + int newSpeedIndex = 0; + for (int i = 0; i < speedChecked.length; i++) { + if (speedChecked[i]) { + newSpeedValues[newSpeedIndex++] = speedValues[i]; + } + } + + UserPreferences.setPlaybackSpeedArray(newSpeedValues); + + } + }); + builder.create().show(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/Chapter.java b/app/src/main/java/de/danoeh/antennapod/feed/Chapter.java new file mode 100644 index 000000000..d6151ee9f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/Chapter.java @@ -0,0 +1,55 @@ +package de.danoeh.antennapod.feed; + +public abstract class Chapter extends FeedComponent { + + /** Defines starting point in milliseconds. */ + protected long start; + protected String title; + protected String link; + + public Chapter() { + } + + public Chapter(long start) { + super(); + this.start = start; + } + + public Chapter(long start, String title, FeedItem item, String link) { + super(); + this.start = start; + this.title = title; + this.link = link; + } + + public abstract int getChapterType(); + + public long getStart() { + return start; + } + + public String getTitle() { + return title; + } + + public String getLink() { + return link; + } + + public void setStart(long start) { + this.start = start; + } + + public void setTitle(String title) { + this.title = title; + } + + public void setLink(String link) { + this.link = link; + } + + @Override + public String getHumanReadableIdentifier() { + return title; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java b/app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java new file mode 100644 index 000000000..5fb72048e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java @@ -0,0 +1,140 @@ +package de.danoeh.antennapod.feed; + +import android.os.Handler; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; + +import java.util.AbstractQueue; +import java.util.Observable; +import java.util.Observer; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Notifies its observers about changes in the feed database. Observers can + * register by retrieving an instance of this class and registering an + * EventListener. When new events arrive, the EventDistributor will process the + * event queue in a handler that runs on the main thread. The observers will only + * be notified once if the event queue contains multiple elements. + * + * Events can be sent with the send* methods. + */ +public class EventDistributor extends Observable { + private static final String TAG = "EventDistributor"; + + public static final int FEED_LIST_UPDATE = 1; + public static final int UNREAD_ITEMS_UPDATE = 2; + public static final int QUEUE_UPDATE = 4; + public static final int DOWNLOADLOG_UPDATE = 8; + public static final int PLAYBACK_HISTORY_UPDATE = 16; + public static final int DOWNLOAD_QUEUED = 32; + public static final int DOWNLOAD_HANDLED = 64; + + private Handler handler; + private AbstractQueue<Integer> events; + + private static EventDistributor instance; + + private EventDistributor() { + this.handler = new Handler(); + events = new ConcurrentLinkedQueue<Integer>(); + } + + public static synchronized EventDistributor getInstance() { + if (instance == null) { + instance = new EventDistributor(); + } + return instance; + } + + public void register(EventListener el) { + addObserver(el); + } + + public void unregister(EventListener el) { + deleteObserver(el); + } + + public void addEvent(Integer i) { + events.offer(i); + handler.post(new Runnable() { + + @Override + public void run() { + processEventQueue(); + } + }); + } + + private void processEventQueue() { + Integer result = 0; + if (BuildConfig.DEBUG) + Log.d(TAG, + "Processing event queue. Number of events: " + + events.size()); + for (Integer current = events.poll(); current != null; current = events + .poll()) { + result |= current; + } + if (result != 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Notifying observers. Data: " + result); + setChanged(); + notifyObservers(result); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Event queue didn't contain any new events. Observers will not be notified."); + } + } + + @Override + public void addObserver(Observer observer) { + super.addObserver(observer); + Validate.isInstanceOf(EventListener.class, observer); + } + + public void sendDownloadQueuedBroadcast() { + addEvent(DOWNLOAD_QUEUED); + } + + public void sendUnreadItemsUpdateBroadcast() { + addEvent(UNREAD_ITEMS_UPDATE); + } + + public void sendQueueUpdateBroadcast() { + addEvent(QUEUE_UPDATE); + } + + public void sendFeedUpdateBroadcast() { + addEvent(FEED_LIST_UPDATE); + } + + public void sendPlaybackHistoryUpdateBroadcast() { + addEvent(PLAYBACK_HISTORY_UPDATE); + } + + public void sendDownloadLogUpdateBroadcast() { + addEvent(DOWNLOADLOG_UPDATE); + } + + public void sendDownloadHandledBroadcast() { + addEvent(DOWNLOAD_HANDLED); + } + + public static abstract class EventListener implements Observer { + + @Override + public void update(Observable observable, Object data) { + if (observable instanceof EventDistributor + && data instanceof Integer) { + update((EventDistributor) observable, (Integer) data); + } + } + + public abstract void update(EventDistributor eventDistributor, + Integer arg); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/Feed.java b/app/src/main/java/de/danoeh/antennapod/feed/Feed.java new file mode 100644 index 000000000..b5415c69c --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/Feed.java @@ -0,0 +1,445 @@ +package de.danoeh.antennapod.feed; + +import android.content.Context; +import android.net.Uri; + +import de.danoeh.antennapod.asynctask.PicassoImageResource; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.EpisodeFilter; +import de.danoeh.antennapod.util.flattr.FlattrStatus; +import de.danoeh.antennapod.util.flattr.FlattrThing; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Data Object for a whole feed + * + * @author daniel + */ +public class Feed extends FeedFile implements FlattrThing, PicassoImageResource { + public static final int FEEDFILETYPE_FEED = 0; + public static final String TYPE_RSS2 = "rss"; + public static final String TYPE_RSS091 = "rss"; + public static final String TYPE_ATOM1 = "atom"; + + private String title; + /** + * Contains 'id'-element in Atom feed. + */ + private String feedIdentifier; + /** + * Link to the website. + */ + private String link; + private String description; + private String language; + /** + * Name of the author + */ + private String author; + private FeedImage image; + private List<FeedItem> items; + /** + * Date of last refresh. + */ + private Date lastUpdate; + private FlattrStatus flattrStatus; + private String paymentLink; + /** + * Feed type, for example RSS 2 or Atom + */ + private String type; + + /** + * Feed preferences + */ + private FeedPreferences preferences; + + /** + * This constructor is used for restoring a feed from the database. + */ + public Feed(long id, Date lastUpdate, String title, String link, String description, String paymentLink, + String author, String language, String type, String feedIdentifier, FeedImage image, String fileUrl, + String downloadUrl, boolean downloaded, FlattrStatus status) { + super(fileUrl, downloadUrl, downloaded); + this.id = id; + this.title = title; + if (lastUpdate != null) { + this.lastUpdate = (Date) lastUpdate.clone(); + } else { + this.lastUpdate = null; + } + this.link = link; + this.description = description; + this.paymentLink = paymentLink; + this.author = author; + this.language = language; + this.type = type; + this.feedIdentifier = feedIdentifier; + this.image = image; + this.flattrStatus = status; + + items = new ArrayList<FeedItem>(); + } + + /** + * This constructor is used for test purposes and uses a default flattr status object. + */ + public Feed(long id, Date lastUpdate, String title, String link, String description, String paymentLink, + String author, String language, String type, String feedIdentifier, FeedImage image, String fileUrl, + String downloadUrl, boolean downloaded) { + this(id, lastUpdate, title, link, description, paymentLink, author, language, type, feedIdentifier, image, + fileUrl, downloadUrl, downloaded, new FlattrStatus()); + } + + /** + * This constructor can be used when parsing feed data. Only the 'lastUpdate' and 'items' field are initialized. + */ + public Feed() { + super(); + items = new ArrayList<FeedItem>(); + lastUpdate = new Date(); + this.flattrStatus = new FlattrStatus(); + } + + /** + * This constructor is used for requesting a feed download (it must not be used for anything else!). It should NOT be + * used if the title of the feed is already known. + */ + public Feed(String url, Date lastUpdate) { + super(null, url, false); + this.lastUpdate = (lastUpdate != null) ? (Date) lastUpdate.clone() : null; + this.flattrStatus = new FlattrStatus(); + } + + /** + * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be + * used if the title of the feed is already known. + */ + public Feed(String url, Date lastUpdate, String title) { + this(url, lastUpdate); + this.title = title; + this.flattrStatus = new FlattrStatus(); + } + + /** + * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be + * used if the title of the feed is already known. + */ + public Feed(String url, Date lastUpdate, String title, String username, String password) { + this(url, lastUpdate, title); + preferences = new FeedPreferences(0, true, username, password); + } + + /** + * Returns the number of FeedItems where 'read' is false. If the 'display + * only episodes' - preference is set to true, this method will only count + * items with episodes. + */ + public int getNumOfNewItems() { + int count = 0; + for (FeedItem item : items) { + if (item.getState() == FeedItem.State.NEW) { + if (!UserPreferences.isDisplayOnlyEpisodes() + || item.getMedia() != null) { + count++; + } + } + } + return count; + } + + /** + * Returns the number of FeedItems where the media started to play but + * wasn't finished yet. + */ + public int getNumOfStartedItems() { + int count = 0; + + for (FeedItem item : items) { + FeedItem.State state = item.getState(); + if (state == FeedItem.State.IN_PROGRESS + || state == FeedItem.State.PLAYING) { + count++; + } + } + return count; + } + + /** + * Returns true if at least one item in the itemlist is unread. + * + * @param enableEpisodeFilter true if this method should only count items with episodes if + * the 'display only episodes' - preference is set to true by the + * user. + */ + public boolean hasNewItems(boolean enableEpisodeFilter) { + for (FeedItem item : items) { + if (item.getState() == FeedItem.State.NEW) { + if (!(enableEpisodeFilter && UserPreferences + .isDisplayOnlyEpisodes()) || item.getMedia() != null) { + return true; + } + } + } + return false; + } + + /** + * Returns the number of FeedItems. + * + * @param enableEpisodeFilter true if this method should only count items with episodes if + * the 'display only episodes' - preference is set to true by the + * user. + */ + public int getNumOfItems(boolean enableEpisodeFilter) { + if (enableEpisodeFilter && UserPreferences.isDisplayOnlyEpisodes()) { + return EpisodeFilter.countItemsWithEpisodes(items); + } else { + return items.size(); + } + } + + /** + * Returns the item at the specified index. + * + * @param enableEpisodeFilter true if this method should ignore items without episdodes if + * the episodes filter has been enabled by the user. + */ + public FeedItem getItemAtIndex(boolean enableEpisodeFilter, int position) { + if (enableEpisodeFilter && UserPreferences.isDisplayOnlyEpisodes()) { + return EpisodeFilter.accessEpisodeByIndex(items, position); + } else { + return items.get(position); + } + } + + /** + * Returns the value that uniquely identifies this Feed. If the + * feedIdentifier attribute is not null, it will be returned. Else it will + * try to return the title. If the title is not given, it will use the link + * of the feed. + */ + public String getIdentifyingValue() { + if (feedIdentifier != null && !feedIdentifier.isEmpty()) { + return feedIdentifier; + } else if (download_url != null && !download_url.isEmpty()) { + return download_url; + } else if (title != null && !title.isEmpty()) { + return title; + } else { + return link; + } + } + + @Override + public String getHumanReadableIdentifier() { + if (title != null) { + return title; + } else { + return download_url; + } + } + + public void updateFromOther(Feed other) { + super.updateFromOther(other); + if (other.title != null) { + title = other.title; + } + if (other.feedIdentifier != null) { + feedIdentifier = other.feedIdentifier; + } + if (other.link != null) { + link = other.link; + } + if (other.description != null) { + description = other.description; + } + if (other.language != null) { + language = other.language; + } + if (other.author != null) { + author = other.author; + } + if (other.paymentLink != null) { + paymentLink = other.paymentLink; + } + if (other.flattrStatus != null) { + flattrStatus = other.flattrStatus; + } + } + + public boolean compareWithOther(Feed other) { + if (super.compareWithOther(other)) { + return true; + } + if (!title.equals(other.title)) { + return true; + } + if (other.feedIdentifier != null) { + if (feedIdentifier == null + || !feedIdentifier.equals(other.feedIdentifier)) { + return true; + } + } + if (other.link != null) { + if (link == null || !link.equals(other.link)) { + return true; + } + } + if (other.description != null) { + if (description == null || !description.equals(other.description)) { + return true; + } + } + if (other.language != null) { + if (language == null || !language.equals(other.language)) { + return true; + } + } + if (other.author != null) { + if (author == null || !author.equals(other.author)) { + return true; + } + } + if (other.paymentLink != null) { + if (paymentLink == null || !paymentLink.equals(other.paymentLink)) { + return true; + } + } + return false; + } + + @Override + public int getTypeAsInt() { + return FEEDFILETYPE_FEED; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public FeedImage getImage() { + return image; + } + + public void setImage(FeedImage image) { + this.image = image; + } + + public List<FeedItem> getItems() { + return items; + } + + public void setItems(List<FeedItem> list) { + this.items = list; + } + + public Date getLastUpdate() { + return (lastUpdate != null) ? (Date) lastUpdate.clone() : null; + } + + public void setLastUpdate(Date lastUpdate) { + this.lastUpdate = (lastUpdate != null) ? (Date) lastUpdate.clone() : null; + } + + public String getFeedIdentifier() { + return feedIdentifier; + } + + public void setFeedIdentifier(String feedIdentifier) { + this.feedIdentifier = feedIdentifier; + } + + public void setFlattrStatus(FlattrStatus status) { + this.flattrStatus = status; + } + + public FlattrStatus getFlattrStatus() { + return flattrStatus; + } + + public String getPaymentLink() { + return paymentLink; + } + + public void setPaymentLink(String paymentLink) { + this.paymentLink = paymentLink; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public void setPreferences(FeedPreferences preferences) { + this.preferences = preferences; + } + + public FeedPreferences getPreferences() { + return preferences; + } + + public void savePreferences(Context context) { + DBWriter.setFeedPreferences(context, preferences); + } + + @Override + public void setId(long id) { + super.setId(id); + if (preferences != null) { + preferences.setFeedID(id); + } + } + + @Override + public Uri getImageUri() { + if (image != null) { + return image.getImageUri(); + } else { + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java new file mode 100644 index 000000000..48b243770 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java @@ -0,0 +1,66 @@ +package de.danoeh.antennapod.feed; + +/** + * Represents every possible component of a feed + * + * @author daniel + */ +public abstract class FeedComponent { + + protected long id; + + public FeedComponent() { + super(); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + /** + * Update this FeedComponent's attributes with the attributes from another + * FeedComponent. This method should only update attributes which where read from + * the feed. + */ + public void updateFromOther(FeedComponent other) { + } + + /** + * Compare's this FeedComponent's attribute values with another FeedComponent's + * attribute values. This method will only compare attributes which were + * read from the feed. + * + * @return true if attribute values are different, false otherwise + */ + public boolean compareWithOther(FeedComponent other) { + return false; + } + + + /** + * Should return a non-null, human-readable String so that the item can be + * identified by the user. Can be title, download-url, etc. + */ + public abstract String getHumanReadableIdentifier(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FeedComponent that = (FeedComponent) o; + + if (id != that.id) return false; + + return true; + } + + @Override + public int hashCode() { + return (int) (id ^ (id >>> 32)); + } +}
\ No newline at end of file diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java new file mode 100644 index 000000000..a05533ebc --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java @@ -0,0 +1,105 @@ +package de.danoeh.antennapod.feed; + +import java.io.File; + +/** + * Represents a component of a Feed that has to be downloaded + */ +public abstract class FeedFile extends FeedComponent { + + protected String file_url; + protected String download_url; + protected boolean downloaded; + + /** + * Creates a new FeedFile object. + * + * @param file_url The location of the FeedFile. If this is null, the downloaded-attribute + * will automatically be set to false. + * @param download_url The location where the FeedFile can be downloaded. + * @param downloaded true if the FeedFile has been downloaded, false otherwise. This parameter + * will automatically be interpreted as false if the file_url is null. + */ + public FeedFile(String file_url, String download_url, boolean downloaded) { + super(); + this.file_url = file_url; + this.download_url = download_url; + this.downloaded = (file_url != null) && downloaded; + } + + public FeedFile() { + this(null, null, false); + } + + public abstract int getTypeAsInt(); + + /** + * Update this FeedFile's attributes with the attributes from another + * FeedFile. This method should only update attributes which where read from + * the feed. + */ + public void updateFromOther(FeedFile other) { + super.updateFromOther(other); + this.download_url = other.download_url; + } + + /** + * Compare's this FeedFile's attribute values with another FeedFile's + * attribute values. This method will only compare attributes which were + * read from the feed. + * + * @return true if attribute values are different, false otherwise + */ + public boolean compareWithOther(FeedFile other) { + if (super.compareWithOther(other)) { + return true; + } + if (!download_url.equals(other.download_url)) { + return true; + } + return false; + } + + /** + * Returns true if the file exists at file_url. + */ + public boolean fileExists() { + if (file_url == null) { + return false; + } else { + File f = new File(file_url); + return f.exists(); + } + } + + public String getFile_url() { + return file_url; + } + + /** + * Changes the file_url of this FeedFile. Setting this value to + * null will also set the downloaded-attribute to false. + */ + public void setFile_url(String file_url) { + this.file_url = file_url; + if (file_url == null) { + downloaded = false; + } + } + + public String getDownload_url() { + return download_url; + } + + public void setDownload_url(String download_url) { + this.download_url = download_url; + } + + public boolean isDownloaded() { + return downloaded; + } + + public void setDownloaded(boolean downloaded) { + this.downloaded = downloaded; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java new file mode 100644 index 000000000..c588f5e71 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java @@ -0,0 +1,77 @@ +package de.danoeh.antennapod.feed; + +import android.net.Uri; + +import de.danoeh.antennapod.asynctask.PicassoImageResource; + +import org.apache.commons.io.IOUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; + + + +public class FeedImage extends FeedFile implements PicassoImageResource { + public static final int FEEDFILETYPE_FEEDIMAGE = 1; + + protected String title; + protected FeedComponent owner; + + public FeedImage(String download_url, String title) { + super(null, download_url, false); + this.download_url = download_url; + this.title = title; + } + + public FeedImage(long id, String title, String file_url, + String download_url, boolean downloaded) { + super(file_url, download_url, downloaded); + this.id = id; + this.title = title; + } + + @Override + public String getHumanReadableIdentifier() { + if (owner != null && owner.getHumanReadableIdentifier() != null) { + return owner.getHumanReadableIdentifier(); + } else { + return download_url; + } + } + + @Override + public int getTypeAsInt() { + return FEEDFILETYPE_FEEDIMAGE; + } + + public FeedImage() { + super(); + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public FeedComponent getOwner() { + return owner; + } + + public void setOwner(FeedComponent owner) { + this.owner = owner; + } + + @Override + public Uri getImageUri() { + if (file_url != null && downloaded) { + return Uri.fromFile(new File(file_url)); + } else { + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java new file mode 100644 index 000000000..78091ea33 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java @@ -0,0 +1,333 @@ +package de.danoeh.antennapod.feed; + +import android.net.Uri; + +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.asynctask.PicassoImageResource; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.ShownotesProvider; +import de.danoeh.antennapod.util.flattr.FlattrStatus; +import de.danoeh.antennapod.util.flattr.FlattrThing; + +import java.io.InputStream; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Data Object for a XML message + * + * @author daniel + */ +public class FeedItem extends FeedComponent implements ShownotesProvider, FlattrThing, PicassoImageResource { + + /** + * The id/guid that can be found in the rss/atom feed. Might not be set. + */ + private String itemIdentifier; + private String title; + /** + * The description of a feeditem. + */ + private String description; + /** + * The content of the content-encoded tag of a feeditem. + */ + private String contentEncoded; + + private String link; + private Date pubDate; + private FeedMedia media; + + private Feed feed; + private long feedId; + + private boolean read; + private String paymentLink; + private FlattrStatus flattrStatus; + private List<Chapter> chapters; + private FeedImage image; + + public FeedItem() { + this.read = true; + this.flattrStatus = new FlattrStatus(); + } + + /** + * This constructor should be used for creating test objects. + */ + public FeedItem(long id, String title, String itemIdentifier, String link, Date pubDate, boolean read, Feed feed) { + this.id = id; + this.title = title; + this.itemIdentifier = itemIdentifier; + this.link = link; + this.pubDate = (pubDate != null) ? (Date) pubDate.clone() : null; + this.read = read; + this.feed = feed; + this.flattrStatus = new FlattrStatus(); + } + + public void updateFromOther(FeedItem other) { + super.updateFromOther(other); + if (other.title != null) { + title = other.title; + } + if (other.getDescription() != null) { + description = other.getDescription(); + } + if (other.getContentEncoded() != null) { + contentEncoded = other.contentEncoded; + } + if (other.link != null) { + link = other.link; + } + if (other.pubDate != null && other.pubDate != pubDate) { + pubDate = other.pubDate; + } + if (other.media != null) { + if (media == null) { + setMedia(other.media); + } else if (media.compareWithOther(other)) { + media.updateFromOther(other); + } + } + if (other.paymentLink != null) { + paymentLink = other.paymentLink; + } + if (other.chapters != null) { + if (chapters == null) { + chapters = other.chapters; + } + } + if (image == null) { + image = other.image; + } + } + + /** + * Returns the value that uniquely identifies this FeedItem. If the + * itemIdentifier attribute is not null, it will be returned. Else it will + * try to return the title. If the title is not given, it will use the link + * of the entry. + */ + public String getIdentifyingValue() { + if (itemIdentifier != null && !itemIdentifier.isEmpty()) { + return itemIdentifier; + } else if (title != null && !title.isEmpty()) { + return title; + } else { + return link; + } + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public Date getPubDate() { + if (pubDate != null) { + return (Date) pubDate.clone(); + } else { + return null; + } + } + + public void setPubDate(Date pubDate) { + if (pubDate != null) { + this.pubDate = (Date) pubDate.clone(); + } else { + this.pubDate = null; + } + } + + public FeedMedia getMedia() { + return media; + } + + /** + * Sets the media object of this FeedItem. If the given + * FeedMedia object is not null, it's 'item'-attribute value + * will also be set to this item. + */ + public void setMedia(FeedMedia media) { + this.media = media; + if (media != null && media.getItem() != this) { + media.setItem(this); + } + } + + public Feed getFeed() { + return feed; + } + + public void setFeed(Feed feed) { + this.feed = feed; + } + + public boolean isRead() { + return read || isInProgress(); + } + + public void setRead(boolean read) { + this.read = read; + } + + private boolean isInProgress() { + return (media != null && media.isInProgress()); + } + + public String getContentEncoded() { + return contentEncoded; + } + + public void setContentEncoded(String contentEncoded) { + this.contentEncoded = contentEncoded; + } + + public void setFlattrStatus(FlattrStatus status) { + this.flattrStatus = status; + } + + public FlattrStatus getFlattrStatus() { + return flattrStatus; + } + + public String getPaymentLink() { + return paymentLink; + } + + public void setPaymentLink(String paymentLink) { + this.paymentLink = paymentLink; + } + + public List<Chapter> getChapters() { + return chapters; + } + + public void setChapters(List<Chapter> chapters) { + this.chapters = chapters; + } + + public String getItemIdentifier() { + return itemIdentifier; + } + + public void setItemIdentifier(String itemIdentifier) { + this.itemIdentifier = itemIdentifier; + } + + public boolean hasMedia() { + return media != null; + } + + private boolean isPlaying() { + if (media != null) { + return media.isPlaying(); + } + return false; + } + + @Override + public Callable<String> loadShownotes() { + return new Callable<String>() { + @Override + public String call() throws Exception { + + if (contentEncoded == null || description == null) { + DBReader.loadExtraInformationOfFeedItem(PodcastApp.getInstance(), FeedItem.this); + + } + return (contentEncoded != null) ? contentEncoded : description; + } + }; + } + + @Override + public Uri getImageUri() { + if (hasMedia()) { + return media.getImageUri(); + } else if (feed != null) { + return feed.getImageUri(); + } else { + return null; + } + } + + public enum State { + NEW, IN_PROGRESS, READ, PLAYING + } + + public State getState() { + if (hasMedia()) { + if (isPlaying()) { + return State.PLAYING; + } + if (isInProgress()) { + return State.IN_PROGRESS; + } + } + return (isRead() ? State.READ : State.NEW); + } + + public long getFeedId() { + return feedId; + } + + public void setFeedId(long feedId) { + this.feedId = feedId; + } + + /** + * Returns the image of this item or the image of the feed if this item does + * not have its own image. + */ + public FeedImage getImage() { + return (hasItemImage()) ? image : feed.getImage(); + } + + public void setImage(FeedImage image) { + this.image = image; + if (image != null) { + image.setOwner(this); + } + } + + /** + * Returns true if this FeedItem has its own image, false otherwise. + */ + public boolean hasItemImage() { + return image != null; + } + + /** + * Returns true if this FeedItem has its own image and the image has been downloaded. + */ + public boolean hasItemImageDownloaded() { + return image != null && image.isDownloaded(); + } + + @Override + public String getHumanReadableIdentifier() { + return title; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java new file mode 100644 index 000000000..9298ebe8a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java @@ -0,0 +1,411 @@ +package de.danoeh.antennapod.feed; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.preferences.PlaybackPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.ChapterUtils; +import de.danoeh.antennapod.util.playback.Playable; + +public class FeedMedia extends FeedFile implements Playable { + private static final String TAG = "FeedMedia"; + + public static final int FEEDFILETYPE_FEEDMEDIA = 2; + public static final int PLAYABLE_TYPE_FEEDMEDIA = 1; + + public static final String PREF_MEDIA_ID = "FeedMedia.PrefMediaId"; + public static final String PREF_FEED_ID = "FeedMedia.PrefFeedId"; + + private int duration; + private int position; // Current position in file + private int played_duration; // How many ms of this file have been played (for autoflattring) + private long size; // File size in Byte + private String mime_type; + private volatile FeedItem item; + private Date playbackCompletionDate; + + /* Used for loading item when restoring from parcel. */ + private long itemID; + + public FeedMedia(FeedItem i, String download_url, long size, + String mime_type) { + super(null, download_url, false); + this.item = i; + this.size = size; + this.mime_type = mime_type; + } + + public FeedMedia(long id, FeedItem item, int duration, int position, + long size, String mime_type, String file_url, String download_url, + boolean downloaded, Date playbackCompletionDate, int played_duration) { + super(file_url, download_url, downloaded); + this.id = id; + this.item = item; + this.duration = duration; + this.position = position; + this.played_duration = played_duration; + this.size = size; + this.mime_type = mime_type; + this.playbackCompletionDate = playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public FeedMedia(long id, FeedItem item) { + super(); + this.id = id; + this.item = item; + } + + @Override + public String getHumanReadableIdentifier() { + if (item != null && item.getTitle() != null) { + return item.getTitle(); + } else { + return download_url; + } + } + + /** + * Uses mimetype to determine the type of media. + */ + public MediaType getMediaType() { + if (mime_type == null || mime_type.isEmpty()) { + return MediaType.UNKNOWN; + } else { + if (mime_type.startsWith("audio")) { + return MediaType.AUDIO; + } else if (mime_type.startsWith("video")) { + return MediaType.VIDEO; + } else if (mime_type.equals("application/ogg")) { + return MediaType.AUDIO; + } + } + return MediaType.UNKNOWN; + } + + public void updateFromOther(FeedMedia other) { + super.updateFromOther(other); + if (other.size > 0) { + size = other.size; + } + if (other.mime_type != null) { + mime_type = other.mime_type; + } + } + + public boolean compareWithOther(FeedMedia other) { + if (super.compareWithOther(other)) { + return true; + } + if (other.mime_type != null) { + if (mime_type == null || !mime_type.equals(other.mime_type)) { + return true; + } + } + if (other.size > 0 && other.size != size) { + return true; + } + return false; + } + + /** + * Reads playback preferences to determine whether this FeedMedia object is + * currently being played. + */ + public boolean isPlaying() { + return PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA + && PlaybackPreferences.getCurrentlyPlayingFeedMediaId() == id; + } + + @Override + public int getTypeAsInt() { + return FEEDFILETYPE_FEEDMEDIA; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public int getPlayedDuration() { + return played_duration; + } + + public void setPlayedDuration(int played_duration) { + this.played_duration = played_duration; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public String getMime_type() { + return mime_type; + } + + public void setMime_type(String mime_type) { + this.mime_type = mime_type; + } + + public FeedItem getItem() { + return item; + } + + /** + * Sets the item object of this FeedMedia. If the given + * FeedItem object is not null, it's 'media'-attribute value + * will also be set to this media object. + */ + public void setItem(FeedItem item) { + this.item = item; + if (item != null && item.getMedia() != this) { + item.setMedia(this); + } + } + + public Date getPlaybackCompletionDate() { + return playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public void setPlaybackCompletionDate(Date playbackCompletionDate) { + this.playbackCompletionDate = playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public boolean isInProgress() { + return (this.position > 0); + } + + public FeedImage getImage() { + if (item != null) { + return (item.hasItemImageDownloaded()) ? item.getImage() : item.getFeed().getImage(); + } + return null; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeLong(item.getId()); + + dest.writeInt(duration); + dest.writeInt(position); + dest.writeLong(size); + dest.writeString(mime_type); + dest.writeString(file_url); + dest.writeString(download_url); + dest.writeByte((byte) ((downloaded) ? 1 : 0)); + dest.writeLong((playbackCompletionDate != null) ? playbackCompletionDate.getTime() : 0); + dest.writeInt(played_duration); + } + + @Override + public void writeToPreferences(Editor prefEditor) { + prefEditor.putLong(PREF_FEED_ID, item.getFeed().getId()); + prefEditor.putLong(PREF_MEDIA_ID, id); + } + + @Override + public void loadMetadata() throws PlayableException { + if (item == null && itemID != 0) { + item = DBReader.getFeedItem(PodcastApp.getInstance(), itemID); + } + } + + @Override + public void loadChapterMarks() { + if (getChapters() == null && !localFileAvailable()) { + ChapterUtils.loadChaptersFromStreamUrl(this); + if (getChapters() != null && item != null) { + DBWriter.setFeedItem(PodcastApp.getInstance(), + item); + } + } + + } + + @Override + public String getEpisodeTitle() { + if (item == null) { + return null; + } + if (getItem().getTitle() != null) { + return getItem().getTitle(); + } else { + return getItem().getIdentifyingValue(); + } + } + + @Override + public List<Chapter> getChapters() { + if (item == null) { + return null; + } + return getItem().getChapters(); + } + + @Override + public String getWebsiteLink() { + if (item == null) { + return null; + } + return getItem().getLink(); + } + + @Override + public String getFeedTitle() { + if (item == null) { + return null; + } + return getItem().getFeed().getTitle(); + } + + @Override + public Object getIdentifier() { + return id; + } + + @Override + public String getLocalMediaUrl() { + return file_url; + } + + @Override + public String getStreamUrl() { + return download_url; + } + + @Override + public String getPaymentLink() { + if (item == null) { + return null; + } + return getItem().getPaymentLink(); + } + + @Override + public boolean localFileAvailable() { + return isDownloaded() && file_url != null; + } + + @Override + public boolean streamAvailable() { + return download_url != null; + } + + @Override + public void saveCurrentPosition(SharedPreferences pref, int newPosition) { + setPosition(newPosition); + DBWriter.setFeedMediaPlaybackInformation(PodcastApp.getInstance(), this); + } + + @Override + public void onPlaybackStart() { + } + + @Override + public void onPlaybackCompleted() { + + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_FEEDMEDIA; + } + + @Override + public void setChapters(List<Chapter> chapters) { + getItem().setChapters(chapters); + } + + @Override + public Callable<String> loadShownotes() { + return new Callable<String>() { + @Override + public String call() throws Exception { + if (item == null) { + item = DBReader.getFeedItem(PodcastApp.getInstance(), itemID); + } + if (item.getContentEncoded() == null || item.getDescription() == null) { + DBReader.loadExtraInformationOfFeedItem(PodcastApp.getInstance(), item); + + } + return (item.getContentEncoded() != null) ? item.getContentEncoded() : item.getDescription(); + } + }; + } + + public static final Parcelable.Creator<FeedMedia> CREATOR = new Parcelable.Creator<FeedMedia>() { + public FeedMedia createFromParcel(Parcel in) { + final long id = in.readLong(); + final long itemID = in.readLong(); + FeedMedia result = new FeedMedia(id, null, in.readInt(), in.readInt(), in.readLong(), in.readString(), in.readString(), + in.readString(), in.readByte() != 0, new Date(in.readLong()), in.readInt()); + result.itemID = itemID; + return result; + } + + public FeedMedia[] newArray(int size) { + return new FeedMedia[size]; + } + }; + + @Override + public Uri getImageUri() { + final Uri feedImgUri = getFeedImageUri(); + + if (localFileAvailable()) { + Uri.Builder builder = new Uri.Builder(); + builder.scheme(SCHEME_MEDIA) + .encodedPath(getLocalMediaUrl()); + if (feedImgUri != null) { + builder.appendQueryParameter(PARAM_FALLBACK, feedImgUri.toString()); + } + return builder.build(); + } else { + return feedImgUri; + } + } + + private Uri getFeedImageUri() { + if (item != null && item.getFeed() != null) { + return item.getFeed().getImageUri(); + } else { + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java new file mode 100644 index 000000000..29bc5ef0c --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java @@ -0,0 +1,89 @@ +package de.danoeh.antennapod.feed; + +import android.content.Context; +import de.danoeh.antennapod.storage.DBWriter; +import org.apache.commons.lang3.StringUtils; + +/** + * Contains preferences for a single feed. + */ +public class FeedPreferences { + + private long feedID; + private boolean autoDownload; + private String username; + private String password; + + public FeedPreferences(long feedID, boolean autoDownload, String username, String password) { + this.feedID = feedID; + this.autoDownload = autoDownload; + this.username = username; + this.password = password; + } + + + /** + * Compare another FeedPreferences with this one. The feedID and autoDownload attribute are excluded from the + * comparison. + * + * @return True if the two objects are different. + */ + public boolean compareWithOther(FeedPreferences other) { + if (other == null) + return true; + if (!StringUtils.equals(username, other.username)) { + return true; + } + if (!StringUtils.equals(password, other.password)) { + return true; + } + return false; + } + + /** + * Update this FeedPreferences object from another one. The feedID and autoDownload attributes are excluded + * from the update. + */ + public void updateFromOther(FeedPreferences other) { + if (other == null) + return; + this.username = other.username; + this.password = other.password; + } + + public long getFeedID() { + return feedID; + } + + public void setFeedID(long feedID) { + this.feedID = feedID; + } + + public boolean getAutoDownload() { + return autoDownload; + } + + public void setAutoDownload(boolean autoDownload) { + this.autoDownload = autoDownload; + } + + public void save(Context context) { + DBWriter.setFeedPreferences(context, this); + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java b/app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java new file mode 100644 index 000000000..6dde7854e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java @@ -0,0 +1,36 @@ +package de.danoeh.antennapod.feed; + +public class ID3Chapter extends Chapter { + public static final int CHAPTERTYPE_ID3CHAPTER = 2; + + /** + * Identifies the chapter in its ID3 tag. This attribute does not have to be + * store in the DB and is only used for parsing. + */ + private String id3ID; + + public ID3Chapter(String id3ID, long start) { + super(start); + this.id3ID = id3ID; + } + + public ID3Chapter(long start, String title, FeedItem item, String link) { + super(start, title, item, link); + } + + @Override + public String toString() { + return "ID3Chapter [id3ID=" + id3ID + ", title=" + title + ", start=" + + start + ", url=" + link + "]"; + } + + @Override + public int getChapterType() { + return CHAPTERTYPE_ID3CHAPTER; + } + + public String getId3ID() { + return id3ID; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/MediaType.java b/app/src/main/java/de/danoeh/antennapod/feed/MediaType.java new file mode 100644 index 000000000..324d0a221 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/MediaType.java @@ -0,0 +1,5 @@ +package de.danoeh.antennapod.feed; + +public enum MediaType { + AUDIO, VIDEO, UNKNOWN +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java b/app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java new file mode 100644 index 000000000..1cba389ec --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.feed; + +public class SearchResult { + private FeedComponent component; + /** Additional information (e.g. where it was found) */ + private String subtitle; + /** Higher value means more importance */ + private int value; + + public SearchResult(FeedComponent component, int value, String subtitle) { + super(); + this.component = component; + this.value = value; + this.subtitle = subtitle; + } + + public FeedComponent getComponent() { + return component; + } + + public String getSubtitle() { + return subtitle; + } + + public void setSubtitle(String subtitle) { + this.subtitle = subtitle; + } + + public int getValue() { + return value; + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java b/app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java new file mode 100644 index 000000000..3dab1b74d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.feed; + +public class SimpleChapter extends Chapter { + public static final int CHAPTERTYPE_SIMPLECHAPTER = 0; + + public SimpleChapter(long start, String title, FeedItem item, String link) { + super(start, title, item, link); + } + + @Override + public int getChapterType() { + return CHAPTERTYPE_SIMPLECHAPTER; + } + + public void updateFromOther(SimpleChapter other) { + super.updateFromOther(other); + start = other.start; + if (other.title != null) { + title = other.title; + } + if (other.link != null) { + link = other.link; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java b/app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java new file mode 100644 index 000000000..59844ae1f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java @@ -0,0 +1,109 @@ +package de.danoeh.antennapod.feed; + +import de.danoeh.antennapod.util.vorbiscommentreader.VorbisCommentReaderException; + +import java.util.concurrent.TimeUnit; + +public class VorbisCommentChapter extends Chapter { + public static final int CHAPTERTYPE_VORBISCOMMENT_CHAPTER = 3; + + private static final int CHAPTERXXX_LENGTH = "chapterxxx".length(); + + private int vorbisCommentId; + + public VorbisCommentChapter(int vorbisCommentId) { + this.vorbisCommentId = vorbisCommentId; + } + + public VorbisCommentChapter(long start, String title, FeedItem item, + String link) { + super(start, title, item, link); + } + + @Override + public String toString() { + return "VorbisCommentChapter [id=" + id + ", title=" + title + + ", link=" + link + ", start=" + start + "]"; + } + + public static long getStartTimeFromValue(String value) + throws VorbisCommentReaderException { + String[] parts = value.split(":"); + if (parts.length >= 3) { + try { + long hours = TimeUnit.MILLISECONDS.convert( + Long.parseLong(parts[0]), TimeUnit.HOURS); + long minutes = TimeUnit.MILLISECONDS.convert( + Long.parseLong(parts[1]), TimeUnit.MINUTES); + if (parts[2].contains("-->")) { + parts[2] = parts[2].substring(0, parts[2].indexOf("-->")); + } + long seconds = TimeUnit.MILLISECONDS.convert( + ((long) Float.parseFloat(parts[2])), TimeUnit.SECONDS); + return hours + minutes + seconds; + } catch (NumberFormatException e) { + throw new VorbisCommentReaderException(e); + } + } else { + throw new VorbisCommentReaderException("Invalid time string"); + } + } + + /** + * Return the id of a vorbiscomment chapter from a string like CHAPTERxxx* + * + * @return the id of the chapter key or -1 if the id couldn't be read. + * @throws VorbisCommentReaderException + * */ + public static int getIDFromKey(String key) + throws VorbisCommentReaderException { + if (key.length() >= CHAPTERXXX_LENGTH) { // >= CHAPTERxxx + try { + String strId = key.substring(8, 10); + return Integer.parseInt(strId); + } catch (NumberFormatException e) { + throw new VorbisCommentReaderException(e); + } + } + throw new VorbisCommentReaderException("key is too short (" + key + ")"); + } + + /** + * Get the string that comes after 'CHAPTERxxx', for example 'name' or + * 'url'. + */ + public static String getAttributeTypeFromKey(String key) { + if (key.length() > CHAPTERXXX_LENGTH) { + return key.substring(CHAPTERXXX_LENGTH, key.length()); + } + return null; + } + + @Override + public int getChapterType() { + return CHAPTERTYPE_VORBISCOMMENT_CHAPTER; + } + + public void setTitle(String title) { + this.title = title; + } + + public void setLink(String link) { + this.link = link; + } + + public void setStart(long start) { + this.start = start; + } + + public int getVorbisCommentId() { + return vorbisCommentId; + } + + public void setVorbisCommentId(int vorbisCommentId) { + this.vorbisCommentId = vorbisCommentId; + } + + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java new file mode 100644 index 000000000..f5ae5a777 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java @@ -0,0 +1,76 @@ +package de.danoeh.antennapod.fragment; + +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.DefaultOnlineFeedViewActivity; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.activity.OnlineFeedViewActivity; +import de.danoeh.antennapod.activity.OpmlImportFromPathActivity; +import de.danoeh.antennapod.fragment.gpodnet.GpodnetMainFragment; + +/** + * Provides actions for adding new podcast subscriptions + */ +public class AddFeedFragment extends Fragment { + private static final String TAG = "AddFeedFragment"; + + /** + * Preset value for url text field. + */ + public static final String ARG_FEED_URL = "feedurl"; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + View root = inflater.inflate(R.layout.addfeed, container, false); + + final EditText etxtFeedurl = (EditText) root.findViewById(R.id.etxtFeedurl); + + Bundle args = getArguments(); + if (args != null && args.getString(ARG_FEED_URL) != null) { + etxtFeedurl.setText(args.getString(ARG_FEED_URL)); + } + + Button butBrowserGpoddernet = (Button) root.findViewById(R.id.butBrowseGpoddernet); + Button butOpmlImport = (Button) root.findViewById(R.id.butOpmlImport); + Button butConfirm = (Button) root.findViewById(R.id.butConfirm); + + final MainActivity activity = (MainActivity) getActivity(); + activity.getMainActivtyActionBar().setTitle(R.string.add_feed_label); + + butBrowserGpoddernet.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + activity.loadChildFragment(new GpodnetMainFragment()); + } + }); + + butOpmlImport.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + startActivity(new Intent(getActivity(), + OpmlImportFromPathActivity.class)); + } + }); + + butConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(getActivity(), DefaultOnlineFeedViewActivity.class); + intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, etxtFeedurl.getText().toString()); + intent.putExtra(OnlineFeedViewActivity.ARG_TITLE, getString(R.string.add_feed_label)); + startActivity(intent); + } + }); + + return root; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java new file mode 100644 index 000000000..082fe93fc --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java @@ -0,0 +1,196 @@ +package de.danoeh.antennapod.fragment; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.ListFragment; +import android.view.View; +import android.widget.ListView; +import de.danoeh.antennapod.adapter.DownloadedEpisodesListAdapter; +import de.danoeh.antennapod.dialog.FeedItemDialog; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.QueueAccess; + +import java.util.List; + +/** + * Displays all running downloads and provides a button to delete them + */ +public class CompletedDownloadsFragment extends ListFragment { + private static final int EVENTS = + EventDistributor.DOWNLOAD_HANDLED | + EventDistributor.DOWNLOADLOG_UPDATE | + EventDistributor.QUEUE_UPDATE | + EventDistributor.UNREAD_ITEMS_UPDATE; + + private List<FeedItem> items; + private QueueAccess queue; + private DownloadedEpisodesListAdapter listAdapter; + + private boolean viewCreated = false; + private boolean itemsLoaded = false; + + private FeedItemDialog feedItemDialog; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + startItemLoader(); + } + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + } + + @Override + public void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(contentUpdate); + stopItemLoader(); + } + + @Override + public void onDetach() { + super.onDetach(); + stopItemLoader(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + listAdapter = null; + viewCreated = false; + feedItemDialog = null; + stopItemLoader(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + if (viewCreated && itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + viewCreated = true; + if (itemsLoaded && getActivity() != null) { + onFragmentLoaded(); + } + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + super.onListItemClick(l, v, position, id); + FeedItem item = listAdapter.getItem(position - l.getHeaderViewsCount()); + if (item != null) { + feedItemDialog = FeedItemDialog.newInstance(getActivity(), item, queue); + feedItemDialog.show(); + } + + } + + private void onFragmentLoaded() { + if (listAdapter == null) { + listAdapter = new DownloadedEpisodesListAdapter(getActivity(), itemAccess); + setListAdapter(listAdapter); + } + setListShown(true); + listAdapter.notifyDataSetChanged(); + if (feedItemDialog != null) { + boolean res = feedItemDialog.updateContent(queue, items); + if (!res && feedItemDialog.isShowing()) { + feedItemDialog.dismiss(); + } + } + } + + private DownloadedEpisodesListAdapter.ItemAccess itemAccess = new DownloadedEpisodesListAdapter.ItemAccess() { + @Override + public int getCount() { + return (items != null) ? items.size() : 0; + } + + @Override + public FeedItem getItem(int position) { + return (items != null) ? items.get(position) : null; + } + + @Override + public void onFeedItemSecondaryAction(FeedItem item) { + DBWriter.deleteFeedMediaOfItem(getActivity(), item.getMedia().getId()); + } + }; + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & EventDistributor.DOWNLOAD_QUEUED) != 0) { + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.updateMenuAppearance(); + } + } else if ((arg & EVENTS) != 0) { + startItemLoader(); + } + } + }; + + private ItemLoader itemLoader; + + private void startItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + itemLoader = new ItemLoader(); + itemLoader.execute(); + } + + private void stopItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + } + + private class ItemLoader extends AsyncTask<Void, Void, Object[]> { + + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (!itemsLoaded && viewCreated) { + setListShown(false); + } + } + + @Override + protected void onPostExecute(Object[] results) { + super.onPostExecute(results); + if (results != null) { + items = (List<FeedItem>) results[0]; + queue = (QueueAccess) results[1]; + itemsLoaded = true; + if (viewCreated && getActivity() != null) { + onFragmentLoaded(); + } + } + } + + @Override + protected Object[] doInBackground(Void... params) { + Context context = getActivity(); + if (context != null) { + return new Object[]{DBReader.getDownloadedItems(context), + QueueAccess.IDListAccess(DBReader.getQueueIDList(context))}; + } + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java new file mode 100644 index 000000000..ffce518bf --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java @@ -0,0 +1,105 @@ +package de.danoeh.antennapod.fragment; + +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.AudioplayerActivity.AudioplayerContentFragment; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.util.playback.Playable; + +/** + * Displays the cover and the title of a FeedItem. + */ +public class CoverFragment extends Fragment implements + AudioplayerContentFragment { + private static final String TAG = "CoverFragment"; + private static final String ARG_PLAYABLE = "arg.playable"; + + private Playable media; + + private ImageView imgvCover; + + private boolean viewCreated = false; + + public static CoverFragment newInstance(Playable item) { + CoverFragment f = new CoverFragment(); + if (item != null) { + Bundle args = new Bundle(); + args.putParcelable(ARG_PLAYABLE, item); + f.setArguments(args); + } + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + Bundle args = getArguments(); + if (args != null) { + media = args.getParcelable(ARG_PLAYABLE); + } else { + Log.e(TAG, TAG + " was called with invalid arguments"); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.cover_fragment, container, false); + imgvCover = (ImageView) root.findViewById(R.id.imgvCover); + viewCreated = true; + return root; + } + + private void loadMediaInfo() { + if (media != null) { + imgvCover.post(new Runnable() { + + @Override + public void run() { + Context c = getActivity(); + if (c != null) { + PicassoProvider.getMediaMetadataPicassoInstance(c) + .load(media.getImageUri()) + .into(imgvCover); + } + } + }); + } else { + Log.w(TAG, "loadMediaInfo was called while media was null"); + } + } + + @Override + public void onStart() { + if (BuildConfig.DEBUG) + Log.d(TAG, "On Start"); + super.onStart(); + if (media != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading media info"); + loadMediaInfo(); + } else { + Log.w(TAG, "Unable to load media info: media was null"); + } + } + + @Override + public void onDataSetChanged(Playable media) { + this.media = media; + if (viewCreated) { + loadMediaInfo(); + } + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java new file mode 100644 index 000000000..d81ba4b86 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java @@ -0,0 +1,121 @@ +package de.danoeh.antennapod.fragment; + +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.ListFragment; +import android.view.View; +import de.danoeh.antennapod.adapter.DownloadLogAdapter; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.storage.DBReader; + +import java.util.List; + +/** + * Shows the download log + */ +public class DownloadLogFragment extends ListFragment { + + private List<DownloadStatus> downloadLog; + private DownloadLogAdapter adapter; + + private boolean viewsCreated = false; + private boolean itemsLoaded = false; + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + startItemLoader(); + } + + @Override + public void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(contentUpdate); + stopItemLoader(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + viewsCreated = true; + if (itemsLoaded) { + onFragmentLoaded(); + } + } + + private void onFragmentLoaded() { + if (adapter == null) { + adapter = new DownloadLogAdapter(getActivity(), itemAccess); + setListAdapter(adapter); + } + setListShown(true); + adapter.notifyDataSetChanged(); + + } + + private DownloadLogAdapter.ItemAccess itemAccess = new DownloadLogAdapter.ItemAccess() { + + @Override + public int getCount() { + return (downloadLog != null) ? downloadLog.size() : 0; + } + + @Override + public DownloadStatus getItem(int position) { + return (downloadLog != null) ? downloadLog.get(position) : null; + } + }; + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & EventDistributor.DOWNLOADLOG_UPDATE) != 0) { + startItemLoader(); + } + } + }; + + private ItemLoader itemLoader; + + private void startItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + itemLoader = new ItemLoader(); + itemLoader.execute(); + } + + private void stopItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + } + + private class ItemLoader extends AsyncTask<Void, Void, List<DownloadStatus>> { + + @Override + protected void onPostExecute(List<DownloadStatus> downloadStatuses) { + super.onPostExecute(downloadStatuses); + if (downloadStatuses != null) { + downloadLog = downloadStatuses; + itemsLoaded = true; + if (viewsCreated) { + onFragmentLoaded(); + } + } + } + + @Override + protected List<DownloadStatus> doInBackground(Void... params) { + Context context = getActivity(); + if (context != null) { + return DBReader.getDownloadLog(context); + } + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java new file mode 100644 index 000000000..5a71cb36b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java @@ -0,0 +1,145 @@ +package de.danoeh.antennapod.fragment; + +import android.app.Activity; +import android.content.res.Resources; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; + +/** + * Shows the CompletedDownloadsFragment and the RunningDownloadsFragment + */ +public class DownloadsFragment extends Fragment { + + public static final String ARG_SELECTED_TAB = "selected_tab"; + + public static final int POS_RUNNING = 0; + public static final int POS_COMPLETED = 1; + public static final int POS_LOG = 2; + + private ViewPager pager; + private MainActivity activity; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + View root = inflater.inflate(R.layout.pager_fragment, container, false); + pager = (ViewPager) root.findViewById(R.id.pager); + DownloadsPagerAdapter pagerAdapter = new DownloadsPagerAdapter(getChildFragmentManager(), getResources()); + pager.setAdapter(pagerAdapter); + final ActionBar actionBar = activity.getMainActivtyActionBar(); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + ActionBar.TabListener tabListener = new ActionBar.TabListener() { + @Override + public void onTabSelected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) { + pager.setCurrentItem(tab.getPosition()); + } + + @Override + public void onTabUnselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) { + + } + + @Override + public void onTabReselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) { + + } + }; + actionBar.removeAllTabs(); + actionBar.addTab(actionBar.newTab() + .setText(R.string.downloads_running_label) + .setTabListener(tabListener)); + actionBar.addTab(actionBar.newTab() + .setText(R.string.downloads_completed_label) + .setTabListener(tabListener)); + actionBar.addTab(actionBar.newTab() + .setText(R.string.downloads_log_label) + .setTabListener(tabListener)); + + pager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + actionBar.setSelectedNavigationItem(position); + } + }); + return root; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (getArguments() != null) { + int tab = getArguments().getInt(ARG_SELECTED_TAB); + pager.setCurrentItem(tab, false); + } + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.activity = (MainActivity) activity; + } + + @Override + public void onDetach() { + super.onDetach(); + activity.getMainActivtyActionBar().removeAllTabs(); + activity.getMainActivtyActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + } + + public class DownloadsPagerAdapter extends FragmentPagerAdapter { + + + + + Resources resources; + + public DownloadsPagerAdapter(FragmentManager fm, Resources resources) { + super(fm); + this.resources = resources; + } + + @Override + public Fragment getItem(int position) { + switch (position) { + case POS_RUNNING: + return new RunningDownloadsFragment(); + case POS_COMPLETED: + return new CompletedDownloadsFragment(); + case POS_LOG: + return new DownloadLogFragment(); + default: + return null; + } + } + + @Override + public int getCount() { + return 3; + } + + @Override + public CharSequence getPageTitle(int position) { + switch (position) { + case POS_RUNNING: + return resources.getString(R.string.downloads_running_label); + case POS_COMPLETED: + return resources.getString(R.string.downloads_completed_label); + case POS_LOG: + return resources.getString(R.string.downloads_log_label); + default: + return super.getPageTitle(position); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java new file mode 100644 index 000000000..985673dd3 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java @@ -0,0 +1,238 @@ +package de.danoeh.antennapod.fragment; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.util.playback.PlaybackController; + +/** + * Fragment which is supposed to be displayed outside of the MediaplayerActivity + * if the PlaybackService is running + */ +public class ExternalPlayerFragment extends Fragment { + private static final String TAG = "ExternalPlayerFragment"; + + private ViewGroup fragmentLayout; + private ImageView imgvCover; + private ViewGroup layoutInfo; + private TextView txtvTitle; + private ImageButton butPlay; + + private PlaybackController controller; + + public ExternalPlayerFragment() { + super(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.external_player_fragment, + container, false); + fragmentLayout = (ViewGroup) root.findViewById(R.id.fragmentLayout); + imgvCover = (ImageView) root.findViewById(R.id.imgvCover); + layoutInfo = (ViewGroup) root.findViewById(R.id.layoutInfo); + txtvTitle = (TextView) root.findViewById(R.id.txtvTitle); + butPlay = (ImageButton) root.findViewById(R.id.butPlay); + + layoutInfo.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + if (BuildConfig.DEBUG) + Log.d(TAG, "layoutInfo was clicked"); + + if (controller.getMedia() != null) { + startActivity(PlaybackService.getPlayerActivityIntent( + getActivity(), controller.getMedia())); + } + } + }); + return root; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + controller = setupPlaybackController(); + butPlay.setOnClickListener(controller.newOnPlayButtonClickListener()); + } + + private PlaybackController setupPlaybackController() { + return new PlaybackController(getActivity(), true) { + + @Override + public void setupGUI() { + } + + @Override + public void onPositionObserverUpdate() { + } + + @Override + public void onReloadNotification(int code) { + } + + @Override + public void onBufferStart() { + // TODO Auto-generated method stub + + } + + @Override + public void onBufferEnd() { + // TODO Auto-generated method stub + + } + + @Override + public void onBufferUpdate(float progress) { + } + + @Override + public void onSleepTimerUpdate() { + } + + @Override + public void handleError(int code) { + } + + @Override + public ImageButton getPlayButton() { + return butPlay; + } + + @Override + public void postStatusMsg(int msg) { + } + + @Override + public void clearStatusMsg() { + } + + @Override + public boolean loadMediaInfo() { + ExternalPlayerFragment fragment = ExternalPlayerFragment.this; + if (fragment != null) { + return fragment.loadMediaInfo(); + } else { + return false; + } + } + + @Override + public void onAwaitingVideoSurface() { + } + + @Override + public void onServiceQueried() { + } + + @Override + public void onShutdownNotification() { + if (fragmentLayout != null) { + fragmentLayout.setVisibility(View.GONE); + } + controller = setupPlaybackController(); + if (butPlay != null) { + butPlay.setOnClickListener(controller + .newOnPlayButtonClickListener()); + } + + } + + @Override + public void onPlaybackEnd() { + if (fragmentLayout != null) { + fragmentLayout.setVisibility(View.GONE); + } + controller = setupPlaybackController(); + if (butPlay != null) { + butPlay.setOnClickListener(controller + .newOnPlayButtonClickListener()); + } + } + + @Override + public void onPlaybackSpeedChange() { + // TODO Auto-generated method stub + + } + }; + } + + @Override + public void onResume() { + super.onResume(); + controller.init(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Fragment is about to be destroyed"); + if (controller != null) { + controller.release(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (controller != null) { + controller.pause(); + } + } + + private boolean loadMediaInfo() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading media info"); + if (controller.serviceAvailable()) { + Playable media = controller.getMedia(); + if (media != null) { + txtvTitle.setText(media.getEpisodeTitle()); + + PicassoProvider.getMediaMetadataPicassoInstance(getActivity()) + .load(media.getImageUri()) + .fit() + .into(imgvCover); + + fragmentLayout.setVisibility(View.VISIBLE); + if (controller.isPlayingVideo()) { + butPlay.setVisibility(View.GONE); + } else { + butPlay.setVisibility(View.VISIBLE); + } + return true; + } else { + Log.w(TAG, + "loadMediaInfo was called while the media object of playbackService was null!"); + return false; + } + } else { + Log.w(TAG, + "loadMediaInfo was called while playbackService was null!"); + return false; + } + } + + private String getPositionString(int position, int duration) { + return Converter.getDurationStringLong(position) + " / " + + Converter.getDurationStringLong(duration); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java new file mode 100644 index 000000000..04c7fbf8e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java @@ -0,0 +1,476 @@ +package de.danoeh.antennapod.fragment; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebSettings.LayoutAlgorithm; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Toast; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.ShareUtils; +import de.danoeh.antennapod.util.ShownotesProvider; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.util.playback.PlaybackController; +import de.danoeh.antennapod.util.playback.Timeline; + +/** + * Displays the description of a Playable object in a Webview. + */ +public class ItemDescriptionFragment extends Fragment { + + private static final String TAG = "ItemDescriptionFragment"; + + private static final String PREF = "ItemDescriptionFragmentPrefs"; + private static final String PREF_SCROLL_Y = "prefScrollY"; + private static final String PREF_PLAYABLE_ID = "prefPlayableId"; + + private static final String ARG_PLAYABLE = "arg.playable"; + private static final String ARG_FEEDITEM_ID = "arg.feeditem"; + + private static final String ARG_SAVE_STATE = "arg.saveState"; + private static final String ARG_HIGHLIGHT_TIMECODES = "arg.highlightTimecodes"; + + private WebView webvDescription; + + private ShownotesProvider shownotesProvider; + private Playable media; + + + private AsyncTask<Void, Void, Void> webViewLoader; + + /** + * URL that was selected via long-press. + */ + private String selectedURL; + + /** + * True if Fragment should save its state (e.g. scrolling position) in a + * shared preference. + */ + private boolean saveState; + + /** + * True if Fragment should highlight timecodes (e.g. time codes in the HH:MM:SS format). + */ + private boolean highlightTimecodes; + + public static ItemDescriptionFragment newInstance(Playable media, + boolean saveState, + boolean highlightTimecodes) { + ItemDescriptionFragment f = new ItemDescriptionFragment(); + Bundle args = new Bundle(); + args.putParcelable(ARG_PLAYABLE, media); + args.putBoolean(ARG_SAVE_STATE, saveState); + args.putBoolean(ARG_HIGHLIGHT_TIMECODES, highlightTimecodes); + f.setArguments(args); + return f; + } + + public static ItemDescriptionFragment newInstance(FeedItem item, boolean saveState, boolean highlightTimecodes) { + ItemDescriptionFragment f = new ItemDescriptionFragment(); + Bundle args = new Bundle(); + args.putLong(ARG_FEEDITEM_ID, item.getId()); + args.putBoolean(ARG_SAVE_STATE, saveState); + args.putBoolean(ARG_HIGHLIGHT_TIMECODES, highlightTimecodes); + f.setArguments(args); + return f; + } + + @SuppressLint("NewApi") + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating view"); + webvDescription = new WebView(getActivity()); + if (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) { + if (Build.VERSION.SDK_INT >= 11 + && Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { + webvDescription.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + } + webvDescription.setBackgroundColor(getResources().getColor( + R.color.black)); + } + webvDescription.getSettings().setUseWideViewPort(false); + webvDescription.getSettings().setLayoutAlgorithm( + LayoutAlgorithm.NARROW_COLUMNS); + webvDescription.getSettings().setLoadWithOverviewMode(true); + webvDescription.setOnLongClickListener(webViewLongClickListener); + webvDescription.setWebViewClient(new WebViewClient() { + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (Timeline.isTimecodeLink(url)) { + onTimecodeLinkSelected(url); + } else { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + e.printStackTrace(); + return true; + } + } + return true; + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + if (BuildConfig.DEBUG) + Log.d(TAG, "Page finished"); + // Restoring the scroll position might not always work + view.postDelayed(new Runnable() { + + @Override + public void run() { + restoreFromPreference(); + } + + }, 50); + } + + }); + + registerForContextMenu(webvDescription); + return webvDescription; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + if (BuildConfig.DEBUG) + Log.d(TAG, "Fragment attached"); + } + + @Override + public void onDetach() { + super.onDetach(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Fragment detached"); + if (webViewLoader != null) { + webViewLoader.cancel(true); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Fragment destroyed"); + if (webViewLoader != null) { + webViewLoader.cancel(true); + } + } + + @SuppressLint("NewApi") + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating fragment"); + Bundle args = getArguments(); + saveState = args.getBoolean(ARG_SAVE_STATE, false); + highlightTimecodes = args.getBoolean(ARG_HIGHLIGHT_TIMECODES, false); + + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + Bundle args = getArguments(); + if (args.containsKey(ARG_PLAYABLE)) { + media = args.getParcelable(ARG_PLAYABLE); + shownotesProvider = media; + startLoader(); + } else if (args.containsKey(ARG_FEEDITEM_ID)) { + AsyncTask<Void, Void, FeedItem> itemLoadTask = new AsyncTask<Void, Void, FeedItem>() { + + @Override + protected FeedItem doInBackground(Void... voids) { + return DBReader.getFeedItem(getActivity(), getArguments().getLong(ARG_FEEDITEM_ID)); + } + + @Override + protected void onPostExecute(FeedItem feedItem) { + super.onPostExecute(feedItem); + shownotesProvider = feedItem; + startLoader(); + } + }; + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + itemLoadTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + itemLoadTask.execute(); + } + } + + + } + + @Override + public void onResume() { + super.onResume(); + } + + @SuppressLint("NewApi") + private void startLoader() { + webViewLoader = createLoader(); + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + webViewLoader.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + webViewLoader.execute(); + } + } + + private View.OnLongClickListener webViewLongClickListener = new View.OnLongClickListener() { + + @Override + public boolean onLongClick(View v) { + WebView.HitTestResult r = webvDescription.getHitTestResult(); + if (r != null + && r.getType() == WebView.HitTestResult.SRC_ANCHOR_TYPE) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Link of webview was long-pressed. Extra: " + + r.getExtra() + ); + selectedURL = r.getExtra(); + webvDescription.showContextMenu(); + return true; + } + selectedURL = null; + return false; + } + }; + + @SuppressWarnings("deprecation") + @SuppressLint("NewApi") + @Override + public boolean onContextItemSelected(MenuItem item) { + boolean handled = selectedURL != null; + if (selectedURL != null) { + switch (item.getItemId()) { + case R.id.open_in_browser_item: + Uri uri = Uri.parse(selectedURL); + getActivity() + .startActivity(new Intent(Intent.ACTION_VIEW, uri)); + break; + case R.id.share_url_item: + ShareUtils.shareLink(getActivity(), selectedURL); + break; + case R.id.copy_url_item: + if (android.os.Build.VERSION.SDK_INT >= 11) { + ClipData clipData = ClipData.newPlainText(selectedURL, + selectedURL); + android.content.ClipboardManager cm = (android.content.ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + cm.setPrimaryClip(clipData); + } else { + android.text.ClipboardManager cm = (android.text.ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + cm.setText(selectedURL); + } + Toast t = Toast.makeText(getActivity(), + R.string.copied_url_msg, Toast.LENGTH_SHORT); + t.show(); + break; + case R.id.go_to_position_item: + if (Timeline.isTimecodeLink(selectedURL)) { + onTimecodeLinkSelected(selectedURL); + } else { + Log.e(TAG, "Selected go_to_position_item, but URL was no timecode link: " + selectedURL); + } + break; + default: + handled = false; + break; + + } + selectedURL = null; + } + return handled; + + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, + ContextMenuInfo menuInfo) { + if (selectedURL != null) { + super.onCreateContextMenu(menu, v, menuInfo); + if (Timeline.isTimecodeLink(selectedURL)) { + menu.add(Menu.NONE, R.id.go_to_position_item, Menu.NONE, + R.string.go_to_position_label); + menu.setHeaderTitle(Converter.getDurationStringLong(Timeline.getTimecodeLinkTime(selectedURL))); + } else { + menu.add(Menu.NONE, R.id.open_in_browser_item, Menu.NONE, + R.string.open_in_browser_label); + menu.add(Menu.NONE, R.id.copy_url_item, Menu.NONE, + R.string.copy_url_label); + menu.add(Menu.NONE, R.id.share_url_item, Menu.NONE, + R.string.share_url_label); + menu.setHeaderTitle(selectedURL); + } + } + } + + private AsyncTask<Void, Void, Void> createLoader() { + return new AsyncTask<Void, Void, Void>() { + @Override + protected void onCancelled() { + super.onCancelled(); + if (getActivity() != null) { + ((ActionBarActivity) getActivity()) + .setSupportProgressBarIndeterminateVisibility(false); + } + webViewLoader = null; + } + + String data; + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + // /webvDescription.loadData(url, "text/html", "utf-8"); + webvDescription.loadDataWithBaseURL(null, data, "text/html", + "utf-8", "about:blank"); + if (getActivity() != null) { + ((ActionBarActivity) getActivity()) + .setSupportProgressBarIndeterminateVisibility(false); + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Webview loaded"); + webViewLoader = null; + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (getActivity() != null) { + ((ActionBarActivity) getActivity()) + .setSupportProgressBarIndeterminateVisibility(false); + } + } + + @Override + protected Void doInBackground(Void... params) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading Webview"); + try { + Activity activity = getActivity(); + if (activity != null) { + Timeline timeline = new Timeline(activity, shownotesProvider); + data = timeline.processShownotes(highlightTimecodes); + } else { + cancel(true); + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + }; + } + + @Override + public void onPause() { + super.onPause(); + savePreference(); + } + + private void savePreference() { + if (saveState) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Saving preferences"); + SharedPreferences prefs = getActivity().getSharedPreferences(PREF, + Activity.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + if (media != null && webvDescription != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Saving scroll position: " + + webvDescription.getScrollY() + ); + editor.putInt(PREF_SCROLL_Y, webvDescription.getScrollY()); + editor.putString(PREF_PLAYABLE_ID, media.getIdentifier() + .toString()); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "savePreferences was called while media or webview was null"); + editor.putInt(PREF_SCROLL_Y, -1); + editor.putString(PREF_PLAYABLE_ID, ""); + } + editor.commit(); + } + } + + private boolean restoreFromPreference() { + if (saveState) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Restoring from preferences"); + Activity activity = getActivity(); + if (activity != null) { + SharedPreferences prefs = activity.getSharedPreferences( + PREF, Activity.MODE_PRIVATE); + String id = prefs.getString(PREF_PLAYABLE_ID, ""); + int scrollY = prefs.getInt(PREF_SCROLL_Y, -1); + if (scrollY != -1 && media != null + && id.equals(media.getIdentifier().toString()) + && webvDescription != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Restored scroll Position: " + scrollY); + webvDescription.scrollTo(webvDescription.getScrollX(), + scrollY); + return true; + } + } + } + return false; + } + + private void onTimecodeLinkSelected(String link) { + int time = Timeline.getTimecodeLinkTime(link); + if (getActivity() != null && getActivity() instanceof ItemDescriptionFragmentCallback) { + PlaybackController pc = ((ItemDescriptionFragmentCallback) getActivity()).getPlaybackController(); + if (pc != null) { + pc.seekTo(time); + } + } + } + + public interface ItemDescriptionFragmentCallback { + public PlaybackController getPlaybackController(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java new file mode 100644 index 000000000..909774467 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java @@ -0,0 +1,456 @@ +package de.danoeh.antennapod.fragment; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.ListFragment; +import android.support.v7.app.ActionBarActivity; +import android.support.v7.widget.SearchView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import org.apache.commons.lang3.Validate; + +import java.util.List; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.FeedInfoActivity; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.adapter.DefaultActionButtonCallback; +import de.danoeh.antennapod.adapter.FeedItemlistAdapter; +import de.danoeh.antennapod.asynctask.DownloadObserver; +import de.danoeh.antennapod.asynctask.FeedRemover; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.dialog.ConfirmationDialog; +import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator; +import de.danoeh.antennapod.dialog.FeedItemDialog; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.service.download.DownloadService; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.menuhandler.FeedMenuHandler; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +/** + * Displays a list of FeedItems. + */ +@SuppressLint("ValidFragment") +public class ItemlistFragment extends ListFragment { + private static final String TAG = "ItemlistFragment"; + + private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED + | EventDistributor.DOWNLOAD_QUEUED + | EventDistributor.QUEUE_UPDATE + | EventDistributor.UNREAD_ITEMS_UPDATE; + + public static final String EXTRA_SELECTED_FEEDITEM = "extra.de.danoeh.antennapod.activity.selected_feeditem"; + public static final String ARGUMENT_FEED_ID = "argument.de.danoeh.antennapod.feed_id"; + + protected FeedItemlistAdapter adapter; + + private long feedID; + private Feed feed; + protected QueueAccess queue; + + private boolean itemsLoaded = false; + private boolean viewsCreated = false; + + private DownloadObserver downloadObserver; + private List<Downloader> downloaderList; + + private FeedItemDialog feedItemDialog; + private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance; + + + /** + * Creates new ItemlistFragment which shows the Feeditems of a specific + * feed. Sets 'showFeedtitle' to false + * + * @param feedId The id of the feed to show + * @return the newly created instance of an ItemlistFragment + */ + public static ItemlistFragment newInstance(long feedId) { + ItemlistFragment i = new ItemlistFragment(); + Bundle b = new Bundle(); + b.putLong(ARGUMENT_FEED_ID, feedId); + i.setArguments(b); + return i; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + setHasOptionsMenu(true); + + Bundle args = getArguments(); + Validate.notNull(args); + feedID = args.getLong(ARGUMENT_FEED_ID); + } + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + if (downloadObserver != null) { + downloadObserver.setActivity(getActivity()); + downloadObserver.onResume(); + } + if (viewsCreated && itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(contentUpdate); + stopItemLoader(); + } + + @Override + public void onResume() { + super.onResume(); + updateProgressBarVisibility(); + startItemLoader(); + } + + @Override + public void onDetach() { + super.onDetach(); + stopItemLoader(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + resetViewState(); + } + + private void resetViewState() { + adapter = null; + viewsCreated = false; + if (downloadObserver != null) { + downloadObserver.onPause(); + } + if (feedItemDialog != null) { + feedItemDialogSavedInstance = feedItemDialog.save(); + } + feedItemDialog = null; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + FeedMenuHandler.onCreateOptionsMenu(inflater, menu); + + final SearchView sv = new SearchView(getActivity()); + MenuItemUtils.addSearchItem(menu, sv); + sv.setQueryHint(getString(R.string.search_hint)); + sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + sv.clearFocus(); + if (itemsLoaded) { + ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(s, feed.getId())); + } + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + } + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + FeedMenuHandler.onPrepareOptionsMenu(menu, feed); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (!super.onOptionsItemSelected(item)) { + try { + if (!FeedMenuHandler.onOptionsItemClicked(getActivity(), item, feed)) { + switch (item.getItemId()) { + case R.id.remove_item: + final FeedRemover remover = new FeedRemover( + getActivity(), feed) { + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + ((MainActivity) getActivity()).loadNavFragment(MainActivity.POS_NEW, null); + } + }; + ConfirmationDialog conDialog = new ConfirmationDialog(getActivity(), + R.string.remove_feed_label, + R.string.feed_delete_confirmation_msg) { + + @Override + public void onConfirmButtonPressed( + DialogInterface dialog) { + dialog.dismiss(); + remover.executeAsync(); + } + }; + conDialog.createNewDialog().show(); + return true; + default: + return false; + + } + } else { + return true; + } + } catch (DownloadRequestException e) { + e.printStackTrace(); + DownloadRequestErrorDialogCreator.newRequestErrorDialog(getActivity(), e.getMessage()); + return true; + } + } else { + return true; + } + + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ((ActionBarActivity) getActivity()).getSupportActionBar().setTitle(""); + + viewsCreated = true; + if (itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + FeedItem selection = adapter.getItem(position - l.getHeaderViewsCount()); + feedItemDialog = FeedItemDialog.newInstance(getActivity(), selection, queue); + feedItemDialog.show(); + } + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((EVENTS & arg) != 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received contentUpdate Intent."); + if ((EventDistributor.DOWNLOAD_QUEUED & arg) != 0) { + updateProgressBarVisibility(); + } else { + startItemLoader(); + updateProgressBarVisibility(); + } + } + } + }; + + private void updateProgressBarVisibility() { + if (feed != null) { + if (DownloadService.isRunning + && DownloadRequester.getInstance().isDownloadingFile(feed)) { + ((ActionBarActivity) getActivity()) + .setSupportProgressBarIndeterminateVisibility(true); + } else { + ((ActionBarActivity) getActivity()) + .setSupportProgressBarIndeterminateVisibility(false); + } + getActivity().supportInvalidateOptionsMenu(); + } + } + + private void onFragmentLoaded() { + if (adapter == null) { + getListView().setAdapter(null); + setupHeaderView(); + adapter = new FeedItemlistAdapter(getActivity(), itemAccess, new DefaultActionButtonCallback(getActivity()), false); + setListAdapter(adapter); + downloadObserver = new DownloadObserver(getActivity(), new Handler(), downloadObserverCallback); + downloadObserver.onResume(); + } + setListShown(true); + adapter.notifyDataSetChanged(); + + if (feedItemDialog != null) { + feedItemDialog.updateContent(queue, feed.getItems()); + } else if (feedItemDialogSavedInstance != null) { + feedItemDialog = FeedItemDialog.newInstance(getActivity(), feedItemDialogSavedInstance); + } + getActivity().supportInvalidateOptionsMenu(); + } + + private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() { + @Override + public void onContentChanged() { + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.updateMenuAppearance(); + } + } + + @Override + public void onDownloadDataAvailable(List<Downloader> downloaderList) { + ItemlistFragment.this.downloaderList = downloaderList; + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + }; + + private void setupHeaderView() { + if (getListView() == null || feed == null) { + Log.e(TAG, "Unable to setup listview: listView = null or feed = null"); + return; + } + ListView lv = getListView(); + LayoutInflater inflater = (LayoutInflater) + getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View header = inflater.inflate(R.layout.feeditemlist_header, lv, false); + lv.addHeaderView(header); + + TextView txtvTitle = (TextView) header.findViewById(R.id.txtvTitle); + TextView txtvAuthor = (TextView) header.findViewById(R.id.txtvAuthor); + ImageView imgvCover = (ImageView) header.findViewById(R.id.imgvCover); + ImageButton butShowInfo = (ImageButton) header.findViewById(R.id.butShowInfo); + ImageButton butVisitWebsite = (ImageButton) header.findViewById(R.id.butVisitWebsite); + + txtvTitle.setText(feed.getTitle()); + txtvAuthor.setText(feed.getAuthor()); + + PicassoProvider.getDefaultPicassoInstance(getActivity()) + .load(feed.getImageUri()) + .fit() + .into(imgvCover); + + if (feed.getLink() == null) { + butVisitWebsite.setVisibility(View.INVISIBLE); + } else { + butVisitWebsite.setVisibility(View.VISIBLE); + butVisitWebsite.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Uri uri = Uri.parse(feed.getLink()); + startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } + }); + } + butShowInfo.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (viewsCreated && itemsLoaded) { + Intent startIntent = new Intent(getActivity(), FeedInfoActivity.class); + startIntent.putExtra(FeedInfoActivity.EXTRA_FEED_ID, + feed.getId()); + startActivity(startIntent); + } + } + }); + } + + private FeedItemlistAdapter.ItemAccess itemAccess = new FeedItemlistAdapter.ItemAccess() { + + @Override + public FeedItem getItem(int position) { + return (feed != null) ? feed.getItemAtIndex(true, position) : null; + } + + @Override + public int getCount() { + return (feed != null) ? feed.getNumOfItems(true) : 0; + } + + @Override + public boolean isInQueue(FeedItem item) { + return (queue != null) && queue.contains(item.getId()); + } + + @Override + public int getItemDownloadProgressPercent(FeedItem item) { + if (downloaderList != null) { + for (Downloader downloader : downloaderList) { + if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA + && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) { + return downloader.getDownloadRequest().getProgressPercent(); + } + } + } + return 0; + } + }; + + private ItemLoader itemLoader; + + private void startItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + itemLoader = new ItemLoader(); + itemLoader.execute(feedID); + } + + private void stopItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + } + + private class ItemLoader extends AsyncTask<Long, Void, Object[]> { + @Override + protected Object[] doInBackground(Long... params) { + long feedID = params[0]; + Context context = getActivity(); + if (context != null) { + return new Object[]{DBReader.getFeed(context, feedID), + QueueAccess.IDListAccess(DBReader.getQueueIDList(context))}; + } else { + return null; + } + } + + @Override + protected void onPostExecute(Object[] res) { + super.onPostExecute(res); + if (res != null) { + feed = (Feed) res[0]; + queue = (QueueAccess) res[1]; + itemsLoaded = true; + if (viewsCreated) { + onFragmentLoaded(); + } + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java new file mode 100644 index 000000000..fe995256b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java @@ -0,0 +1,425 @@ +package de.danoeh.antennapod.fragment; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBarActivity; +import android.support.v7.widget.SearchView; +import android.view.*; +import android.widget.AdapterView; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import com.mobeta.android.dslv.DragSortListView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.adapter.DefaultActionButtonCallback; +import de.danoeh.antennapod.adapter.NewEpisodesListAdapter; +import de.danoeh.antennapod.asynctask.DownloadObserver; +import de.danoeh.antennapod.dialog.FeedItemDialog; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.download.DownloadService; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Shows unread or recently published episodes + */ +public class NewEpisodesFragment extends Fragment { + private static final String TAG = "NewEpisodesFragment"; + private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED | + EventDistributor.DOWNLOAD_QUEUED | + EventDistributor.QUEUE_UPDATE | + EventDistributor.UNREAD_ITEMS_UPDATE; + + private static final int RECENT_EPISODES_LIMIT = 150; + private static final String PREF_NAME = "PrefNewEpisodesFragment"; + private static final String PREF_EPISODE_FILTER_BOOL = "newEpisodeFilterEnabled"; + + + private DragSortListView listView; + private NewEpisodesListAdapter listAdapter; + private TextView txtvEmpty; + private ProgressBar progLoading; + + private List<FeedItem> unreadItems; + private List<FeedItem> recentItems; + private QueueAccess queueAccess; + private List<Downloader> downloaderList; + + private boolean itemsLoaded = false; + private boolean viewsCreated = false; + private boolean showOnlyNewEpisodes = false; + + private AtomicReference<MainActivity> activity = new AtomicReference<MainActivity>(); + + private DownloadObserver downloadObserver = null; + + private FeedItemDialog feedItemDialog; + private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + setHasOptionsMenu(true); + + updateShowOnlyEpisodes(); + } + + @Override + public void onResume() { + super.onResume(); + startItemLoader(); + } + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + this.activity.set((MainActivity) getActivity()); + if (downloadObserver != null) { + downloadObserver.setActivity(getActivity()); + downloadObserver.onResume(); + } + if (viewsCreated && itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(contentUpdate); + stopItemLoader(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.activity.set((MainActivity) getActivity()); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + resetViewState(); + } + + private void resetViewState() { + listAdapter = null; + activity.set(null); + viewsCreated = false; + if (downloadObserver != null) { + downloadObserver.onPause(); + } + if (feedItemDialog != null) { + feedItemDialogSavedInstance = feedItemDialog.save(); + } + feedItemDialog = null; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + inflater.inflate(R.menu.new_episodes, menu); + + final SearchView sv = new SearchView(getActivity()); + MenuItemUtils.addSearchItem(menu, sv); + sv.setQueryHint(getString(R.string.search_hint)); + sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + sv.clearFocus(); + ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(s)); + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + } + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + menu.findItem(R.id.mark_all_read_item).setVisible(unreadItems != null && !unreadItems.isEmpty()); + menu.findItem(R.id.episode_filter_item).setChecked(showOnlyNewEpisodes); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (!super.onOptionsItemSelected(item)) { + switch (item.getItemId()) { + case R.id.refresh_item: + List<Feed> feeds = ((MainActivity) getActivity()).getFeeds(); + if (feeds != null) { + DBTasks.refreshAllFeeds(getActivity(), feeds); + } + return true; + case R.id.mark_all_read_item: + DBWriter.markAllItemsRead(getActivity()); + Toast.makeText(getActivity(), R.string.mark_all_read_msg, Toast.LENGTH_SHORT).show(); + return true; + case R.id.episode_filter_item: + boolean newVal = !item.isChecked(); + setShowOnlyNewEpisodes(newVal); + item.setChecked(newVal); + return true; + default: + return false; + } + } else { + return true; + } + + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + ((MainActivity) getActivity()).getSupportActionBar().setTitle(R.string.all_episodes_label); + + View root = inflater.inflate(R.layout.new_episodes_fragment, container, false); + + listView = (DragSortListView) root.findViewById(android.R.id.list); + txtvEmpty = (TextView) root.findViewById(android.R.id.empty); + progLoading = (ProgressBar) root.findViewById(R.id.progLoading); + + listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + FeedItem item = (FeedItem) listAdapter.getItem(position - listView.getHeaderViewsCount()); + if (item != null) { + feedItemDialog = FeedItemDialog.newInstance(activity.get(), item, queueAccess); + feedItemDialog.show(); + } + + } + }); + + final int secondColor = (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) ? R.color.swipe_refresh_secondary_color_dark : R.color.swipe_refresh_secondary_color_light; + + if (!itemsLoaded) { + progLoading.setVisibility(View.VISIBLE); + txtvEmpty.setVisibility(View.GONE); + } + + viewsCreated = true; + + if (itemsLoaded && activity.get() != null) { + onFragmentLoaded(); + } + + return root; + } + + private void onFragmentLoaded() { + if (listAdapter == null) { + listAdapter = new NewEpisodesListAdapter(activity.get(), itemAccess, new DefaultActionButtonCallback(activity.get())); + listView.setAdapter(listAdapter); + listView.setEmptyView(txtvEmpty); + downloadObserver = new DownloadObserver(activity.get(), new Handler(), downloadObserverCallback); + downloadObserver.onResume(); + } + if (feedItemDialog != null) { + feedItemDialog.updateContent(queueAccess, unreadItems, recentItems); + } else if (feedItemDialogSavedInstance != null) { + feedItemDialog = FeedItemDialog.newInstance(activity.get(), feedItemDialogSavedInstance); + } + listAdapter.notifyDataSetChanged(); + getActivity().supportInvalidateOptionsMenu(); + updateProgressBarVisibility(); + updateShowOnlyEpisodesListViewState(); + } + + private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() { + @Override + public void onContentChanged() { + if (listAdapter != null) { + listAdapter.notifyDataSetChanged(); + } + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.updateMenuAppearance(); + } + } + + @Override + public void onDownloadDataAvailable(List<Downloader> downloaderList) { + NewEpisodesFragment.this.downloaderList = downloaderList; + if (listAdapter != null) { + listAdapter.notifyDataSetChanged(); + } + } + }; + + private NewEpisodesListAdapter.ItemAccess itemAccess = new NewEpisodesListAdapter.ItemAccess() { + + @Override + public int getCount() { + if (itemsLoaded) { + return (showOnlyNewEpisodes) ? unreadItems.size() : recentItems.size(); + } + return 0; + } + + @Override + public FeedItem getItem(int position) { + if (itemsLoaded) { + return (showOnlyNewEpisodes) ? unreadItems.get(position) : recentItems.get(position); + } + return null; + } + + @Override + public int getItemDownloadProgressPercent(FeedItem item) { + if (downloaderList != null) { + for (Downloader downloader : downloaderList) { + if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA + && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) { + return downloader.getDownloadRequest().getProgressPercent(); + } + } + } + return 0; + } + + @Override + public boolean isInQueue(FeedItem item) { + if (itemsLoaded) { + return queueAccess.contains(item.getId()); + } else { + return false; + } + } + + + }; + + private void updateProgressBarVisibility() { + if (!viewsCreated) { + return; + } + ((ActionBarActivity) getActivity()) + .setSupportProgressBarIndeterminateVisibility(DownloadService.isRunning + && DownloadRequester.getInstance().isDownloadingFeeds()); + } + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & EVENTS) != 0) { + startItemLoader(); + updateProgressBarVisibility(); + } + } + }; + + private void updateShowOnlyEpisodes() { + SharedPreferences prefs = getActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + showOnlyNewEpisodes = prefs.getBoolean(PREF_EPISODE_FILTER_BOOL, false); + } + + private void setShowOnlyNewEpisodes(boolean newVal) { + showOnlyNewEpisodes = newVal; + SharedPreferences prefs = getActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(PREF_EPISODE_FILTER_BOOL, showOnlyNewEpisodes); + editor.commit(); + if (itemsLoaded && viewsCreated) { + listAdapter.notifyDataSetChanged(); + activity.get().supportInvalidateOptionsMenu(); + updateShowOnlyEpisodesListViewState(); + } + } + + private void updateShowOnlyEpisodesListViewState() { + if (showOnlyNewEpisodes) { + listView.setEmptyView(null); + txtvEmpty.setVisibility(View.GONE); + } else { + listView.setEmptyView(txtvEmpty); + } + } + + private ItemLoader itemLoader; + + private void startItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + itemLoader = new ItemLoader(); + itemLoader.execute(); + } + + private void stopItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + } + + private class ItemLoader extends AsyncTask<Void, Void, Object[]> { + + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (viewsCreated && !itemsLoaded) { + listView.setVisibility(View.GONE); + txtvEmpty.setVisibility(View.GONE); + progLoading.setVisibility(View.VISIBLE); + } + } + + @Override + protected Object[] doInBackground(Void... params) { + Context context = activity.get(); + if (context != null) { + return new Object[]{DBReader.getUnreadItemsList(context), + DBReader.getRecentlyPublishedEpisodes(context, RECENT_EPISODES_LIMIT), + QueueAccess.IDListAccess(DBReader.getQueueIDList(context))}; + } else { + return null; + } + } + + @Override + protected void onPostExecute(Object[] lists) { + super.onPostExecute(lists); + listView.setVisibility(View.VISIBLE); + progLoading.setVisibility(View.GONE); + + if (lists != null) { + unreadItems = (List<FeedItem>) lists[0]; + recentItems = (List<FeedItem>) lists[1]; + queueAccess = (QueueAccess) lists[2]; + itemsLoaded = true; + if (viewsCreated && activity.get() != null) { + onFragmentLoaded(); + } + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java new file mode 100644 index 000000000..470186180 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java @@ -0,0 +1,288 @@ +package de.danoeh.antennapod.fragment; + +import android.app.Activity; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.ListFragment; +import android.support.v4.view.MenuItemCompat; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ListView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.adapter.DefaultActionButtonCallback; +import de.danoeh.antennapod.adapter.FeedItemlistAdapter; +import de.danoeh.antennapod.asynctask.DownloadObserver; +import de.danoeh.antennapod.dialog.FeedItemDialog; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +public class PlaybackHistoryFragment extends ListFragment { + private static final String TAG = "PlaybackHistoryFragment"; + + private List<FeedItem> playbackHistory; + private QueueAccess queue; + private FeedItemlistAdapter adapter; + + private boolean itemsLoaded = false; + private boolean viewsCreated = false; + + private AtomicReference<Activity> activity = new AtomicReference<Activity>(); + + private DownloadObserver downloadObserver; + private List<Downloader> downloaderList; + + private FeedItemDialog feedItemDialog; + private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + setHasOptionsMenu(true); + } + + @Override + public void onResume() { + super.onResume(); + startItemLoader(); + } + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + } + + @Override + public void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(contentUpdate); + stopItemLoader(); + } + + @Override + public void onDetach() { + super.onDetach(); + stopItemLoader(); + activity.set(null); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.activity.set(activity); + if (downloadObserver != null) { + downloadObserver.setActivity(activity); + downloadObserver.onResume(); + } + if (viewsCreated && itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + adapter = null; + viewsCreated = false; + if (downloadObserver != null) { + downloadObserver.onPause(); + } + if (feedItemDialog != null) { + feedItemDialogSavedInstance = feedItemDialog.save(); + } + feedItemDialog = null; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + viewsCreated = true; + if (itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + super.onListItemClick(l, v, position, id); + FeedItem item = adapter.getItem(position - l.getHeaderViewsCount()); + if (item != null) { + feedItemDialog = FeedItemDialog.newInstance(activity.get(), item, queue); + feedItemDialog.show(); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + MenuItem clearHistory = menu.add(Menu.NONE, R.id.clear_history_item, Menu.CATEGORY_CONTAINER, R.string.clear_history_label); + MenuItemCompat.setShowAsAction(clearHistory, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); + TypedArray drawables = getActivity().obtainStyledAttributes(new int[]{R.attr.content_discard}); + clearHistory.setIcon(drawables.getDrawable(0)); + drawables.recycle(); + } + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + menu.findItem(R.id.clear_history_item).setVisible(playbackHistory != null && !playbackHistory.isEmpty()); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (!super.onOptionsItemSelected(item)) { + switch(item.getItemId()) { + case R.id.clear_history_item: + DBWriter.clearPlaybackHistory(getActivity()); + return true; + default: + return false; + } + } else { + return true; + } + } + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & EventDistributor.PLAYBACK_HISTORY_UPDATE) != 0) { + startItemLoader(); + getActivity().supportInvalidateOptionsMenu(); + } + } + }; + + private void onFragmentLoaded() { + if (adapter == null) { + adapter = new FeedItemlistAdapter(getActivity(), itemAccess, new DefaultActionButtonCallback(activity.get()), true); + setListAdapter(adapter); + downloadObserver = new DownloadObserver(activity.get(), new Handler(), downloadObserverCallback); + downloadObserver.onResume(); + } + setListShown(true); + adapter.notifyDataSetChanged(); + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.updateContent(queue, playbackHistory); + } else if (feedItemDialogSavedInstance != null) { + feedItemDialog = FeedItemDialog.newInstance(activity.get(), feedItemDialogSavedInstance); + } + getActivity().supportInvalidateOptionsMenu(); + } + + private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() { + @Override + public void onContentChanged() { + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.updateMenuAppearance(); + } + } + + @Override + public void onDownloadDataAvailable(List<Downloader> downloaderList) { + PlaybackHistoryFragment.this.downloaderList = downloaderList; + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + }; + + private FeedItemlistAdapter.ItemAccess itemAccess = new FeedItemlistAdapter.ItemAccess() { + @Override + public boolean isInQueue(FeedItem item) { + return (queue != null) ? queue.contains(item.getId()) : false; + } + + @Override + public int getItemDownloadProgressPercent(FeedItem item) { + if (downloaderList != null) { + for (Downloader downloader : downloaderList) { + if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA + && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) { + return downloader.getDownloadRequest().getProgressPercent(); + } + } + } + return 0; + } + + @Override + public int getCount() { + return (playbackHistory != null) ? playbackHistory.size() : 0; + } + + @Override + public FeedItem getItem(int position) { + return (playbackHistory != null) ? playbackHistory.get(position) : null; + } + }; + + private ItemLoader itemLoader; + + private void startItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + itemLoader = new ItemLoader(); + itemLoader.execute(); + } + + private void stopItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + } + + private class ItemLoader extends AsyncTask<Void, Void, Object[]> { + + @Override + protected Object[] doInBackground(Void... params) { + Context context = activity.get(); + if (context != null) { + List<FeedItem> ph = DBReader.getPlaybackHistory(context); + DBReader.loadFeedDataOfFeedItemlist(context, ph); + return new Object[]{ph, + QueueAccess.IDListAccess(DBReader.getQueueIDList(context))}; + } else { + return null; + } + } + + @Override + protected void onPostExecute(Object[] res) { + super.onPostExecute(res); + if (res != null) { + playbackHistory = (List<FeedItem>) res[0]; + queue = (QueueAccess) res[1]; + itemsLoaded = true; + if (viewsCreated) { + onFragmentLoaded(); + } + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java new file mode 100644 index 000000000..2f322f75b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java @@ -0,0 +1,383 @@ +package de.danoeh.antennapod.fragment; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.Fragment; +import android.support.v7.widget.SearchView; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.mobeta.android.dslv.DragSortListView; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.adapter.DefaultActionButtonCallback; +import de.danoeh.antennapod.adapter.QueueListAdapter; +import de.danoeh.antennapod.asynctask.DownloadObserver; +import de.danoeh.antennapod.dialog.FeedItemDialog; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +/** + * Shows all items in the queue + */ +public class QueueFragment extends Fragment { + private static final String TAG = "QueueFragment"; + private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED | + EventDistributor.DOWNLOAD_QUEUED | + EventDistributor.QUEUE_UPDATE; + + private DragSortListView listView; + private QueueListAdapter listAdapter; + private TextView txtvEmpty; + private ProgressBar progLoading; + + private List<FeedItem> queue; + private List<Downloader> downloaderList; + + private boolean itemsLoaded = false; + private boolean viewsCreated = false; + + private AtomicReference<Activity> activity = new AtomicReference<Activity>(); + + private DownloadObserver downloadObserver = null; + + private FeedItemDialog feedItemDialog; + private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance; + + /** + * Download observer updates won't result in an upate of the list adapter if this is true. + */ + private boolean blockDownloadObserverUpdate = false; + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + setHasOptionsMenu(true); + } + + @Override + public void onResume() { + super.onResume(); + startItemLoader(); + } + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + this.activity.set((MainActivity) getActivity()); + if (downloadObserver != null) { + downloadObserver.setActivity(getActivity()); + downloadObserver.onResume(); + } + if (viewsCreated && itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onStop() { + super.onStop(); + EventDistributor.getInstance().unregister(contentUpdate); + stopItemLoader(); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.activity.set((MainActivity) activity); + } + + private void resetViewState() { + unregisterForContextMenu(listView); + listAdapter = null; + activity.set(null); + viewsCreated = false; + blockDownloadObserverUpdate = false; + if (downloadObserver != null) { + downloadObserver.onPause(); + } + if (feedItemDialog != null) { + feedItemDialogSavedInstance = feedItemDialog.save(); + } + feedItemDialog = null; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + resetViewState(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + final SearchView sv = new SearchView(getActivity()); + MenuItemUtils.addSearchItem(menu, sv); + sv.setQueryHint(getString(R.string.search_hint)); + sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + sv.clearFocus(); + ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(s)); + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + } + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + AdapterView.AdapterContextMenuInfo adapterInfo = (AdapterView.AdapterContextMenuInfo) menuInfo; + FeedItem item = itemAccess.getItem(adapterInfo.position); + + MenuInflater inflater = getActivity().getMenuInflater(); + inflater.inflate(R.menu.queue_context, menu); + + if (item != null) { + menu.setHeaderTitle(item.getTitle()); + } + + menu.findItem(R.id.move_to_top_item).setEnabled(!queue.isEmpty() && queue.get(0) != item); + menu.findItem(R.id.move_to_bottom_item).setEnabled(!queue.isEmpty() && queue.get(queue.size() - 1) != item); + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + AdapterView.AdapterContextMenuInfo menuInfo = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo(); + FeedItem selectedItem = itemAccess.getItem(menuInfo.position); + + if (selectedItem == null) { + Log.i(TAG, "Selected item at position " + menuInfo.position + " was null, ignoring selection"); + return super.onContextItemSelected(item); + } + + switch (item.getItemId()) { + case R.id.move_to_top_item: + DBWriter.moveQueueItemToTop(getActivity(), selectedItem.getId(), true); + return true; + case R.id.move_to_bottom_item: + DBWriter.moveQueueItemToBottom(getActivity(), selectedItem.getId(), true); + return true; + case R.id.remove_from_queue_item: + DBWriter.removeQueueItem(getActivity(), selectedItem.getId(), false); + return true; + default: + return super.onContextItemSelected(item); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + ((MainActivity) getActivity()).getSupportActionBar().setTitle(R.string.queue_label); + + View root = inflater.inflate(R.layout.queue_fragment, container, false); + listView = (DragSortListView) root.findViewById(android.R.id.list); + txtvEmpty = (TextView) root.findViewById(android.R.id.empty); + progLoading = (ProgressBar) root.findViewById(R.id.progLoading); + listView.setEmptyView(txtvEmpty); + + listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + FeedItem item = (FeedItem) listAdapter.getItem(position - listView.getHeaderViewsCount()); + if (item != null) { + feedItemDialog = FeedItemDialog.newInstance(activity.get(), item, QueueAccess.ItemListAccess(queue)); + feedItemDialog.show(); + } + } + }); + + listView.setDragSortListener(new DragSortListView.DragSortListener() { + @Override + public void drag(int from, int to) { + Log.d(TAG, "drag"); + blockDownloadObserverUpdate = true; + } + + @Override + public void drop(int from, int to) { + Log.d(TAG, "drop"); + blockDownloadObserverUpdate = false; + stopItemLoader(); + final FeedItem item = queue.remove(from); + queue.add(to, item); + listAdapter.notifyDataSetChanged(); + DBWriter.moveQueueItem(getActivity(), from, to, true); + } + + @Override + public void remove(int which) { + } + }); + + registerForContextMenu(listView); + + if (!itemsLoaded) { + progLoading.setVisibility(View.VISIBLE); + txtvEmpty.setVisibility(View.GONE); + } + + viewsCreated = true; + + if (itemsLoaded && activity.get() != null) { + onFragmentLoaded(); + } + + return root; + } + + private void onFragmentLoaded() { + if (listAdapter == null) { + listAdapter = new QueueListAdapter(activity.get(), itemAccess, new DefaultActionButtonCallback(activity.get())); + listView.setAdapter(listAdapter); + downloadObserver = new DownloadObserver(activity.get(), new Handler(), downloadObserverCallback); + downloadObserver.onResume(); + } + listAdapter.notifyDataSetChanged(); + if (feedItemDialog != null) { + feedItemDialog.updateContent(QueueAccess.ItemListAccess(queue), queue); + } else if (feedItemDialogSavedInstance != null) { + feedItemDialog = FeedItemDialog.newInstance(activity.get(), feedItemDialogSavedInstance); + } + } + + private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() { + @Override + public void onContentChanged() { + if (listAdapter != null && !blockDownloadObserverUpdate) { + listAdapter.notifyDataSetChanged(); + } + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.updateMenuAppearance(); + } + } + + @Override + public void onDownloadDataAvailable(List<Downloader> downloaderList) { + QueueFragment.this.downloaderList = downloaderList; + if (listAdapter != null && !blockDownloadObserverUpdate) { + listAdapter.notifyDataSetChanged(); + } + } + }; + + private QueueListAdapter.ItemAccess itemAccess = new QueueListAdapter.ItemAccess() { + @Override + public int getCount() { + return (itemsLoaded) ? queue.size() : 0; + } + + @Override + public FeedItem getItem(int position) { + return (itemsLoaded) ? queue.get(position) : null; + } + + @Override + public int getItemDownloadProgressPercent(FeedItem item) { + if (downloaderList != null) { + for (Downloader downloader : downloaderList) { + if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA + && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) { + return downloader.getDownloadRequest().getProgressPercent(); + } + } + } + return 0; + } + }; + + private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & EVENTS) != 0) { + startItemLoader(); + } + } + }; + + private ItemLoader itemLoader; + + private void startItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + itemLoader = new ItemLoader(); + itemLoader.execute(); + } + + private void stopItemLoader() { + if (itemLoader != null) { + itemLoader.cancel(true); + } + } + + private class ItemLoader extends AsyncTask<Void, Void, List<FeedItem>> { + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (viewsCreated && !itemsLoaded) { + listView.setVisibility(View.GONE); + txtvEmpty.setVisibility(View.GONE); + progLoading.setVisibility(View.VISIBLE); + } + } + + @Override + protected void onPostExecute(List<FeedItem> feedItems) { + super.onPostExecute(feedItems); + listView.setVisibility(View.VISIBLE); + progLoading.setVisibility(View.GONE); + + if (feedItems != null) { + queue = feedItems; + itemsLoaded = true; + if (viewsCreated && activity.get() != null) { + onFragmentLoaded(); + } + } + } + + @Override + protected List<FeedItem> doInBackground(Void... params) { + Context context = activity.get(); + if (context != null) { + return DBReader.getQueue(context); + } + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java new file mode 100644 index 000000000..89c30e34b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.fragment; + +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.ListFragment; +import android.view.View; +import de.danoeh.antennapod.adapter.DownloadlistAdapter; +import de.danoeh.antennapod.asynctask.DownloadObserver; +import de.danoeh.antennapod.service.download.Downloader; +import de.danoeh.antennapod.storage.DownloadRequester; + +import java.util.List; + +/** + * Displays all running downloads and provides actions to cancel them + */ +public class RunningDownloadsFragment extends ListFragment { + private static final String TAG = "RunningDownloadsFragment"; + + private DownloadObserver downloadObserver; + private List<Downloader> downloaderList; + + + @Override + public void onDetach() { + super.onDetach(); + if (downloadObserver != null) { + downloadObserver.onPause(); + } + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + final DownloadlistAdapter downloadlistAdapter = new DownloadlistAdapter(getActivity(), itemAccess); + setListAdapter(downloadlistAdapter); + + downloadObserver = new DownloadObserver(getActivity(), new Handler(), new DownloadObserver.Callback() { + @Override + public void onContentChanged() { + downloadlistAdapter.notifyDataSetChanged(); + } + + @Override + public void onDownloadDataAvailable(List<Downloader> downloaderList) { + RunningDownloadsFragment.this.downloaderList = downloaderList; + downloadlistAdapter.notifyDataSetChanged(); + } + }); + downloadObserver.onResume(); + } + + private DownloadlistAdapter.ItemAccess itemAccess = new DownloadlistAdapter.ItemAccess() { + @Override + public int getCount() { + return (downloaderList != null) ? downloaderList.size() : 0; + } + + @Override + public Downloader getItem(int position) { + return (downloaderList != null) ? downloaderList.get(position) : null; + } + + @Override + public void onSecondaryActionClick(Downloader downloader) { + DownloadRequester.getInstance().cancelDownload(getActivity(), downloader.getDownloadRequest().getSource()); + } + }; +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java new file mode 100644 index 000000000..b3ade4d70 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java @@ -0,0 +1,258 @@ +package de.danoeh.antennapod.fragment; + +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.ListFragment; +import android.support.v4.view.MenuItemCompat; +import android.support.v7.app.ActionBarActivity; +import android.support.v7.widget.SearchView; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.ListView; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.adapter.SearchlistAdapter; +import de.danoeh.antennapod.dialog.FeedItemDialog; +import de.danoeh.antennapod.feed.*; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.FeedSearcher; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +import java.util.List; + +/** + * Performs a search operation on all feeds or one specific feed and displays the search result. + */ +public class SearchFragment extends ListFragment { + private static final String TAG = "SearchFragment"; + + private static final String ARG_QUERY = "query"; + private static final String ARG_FEED = "feed"; + + private SearchlistAdapter searchAdapter; + private List<SearchResult> searchResults; + + private boolean viewCreated = false; + private boolean itemsLoaded = false; + + private QueueAccess queue; + + private FeedItemDialog feedItemDialog; + private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance; + + /** + * Create a new SearchFragment that searches all feeds. + */ + public static SearchFragment newInstance(String query) { + if (query == null) query = ""; + SearchFragment fragment = new SearchFragment(); + Bundle args = new Bundle(); + args.putString(ARG_QUERY, query); + args.putLong(ARG_FEED, 0); + fragment.setArguments(args); + return fragment; + } + + /** + * Create a new SearchFragment that searches one specific feed. + */ + public static SearchFragment newInstance(String query, long feed) { + SearchFragment fragment = newInstance(query); + fragment.getArguments().putLong(ARG_FEED, feed); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + setHasOptionsMenu(true); + startSearchTask(); + } + + @Override + public void onStart() { + super.onStart(); + EventDistributor.getInstance().register(contentUpdate); + } + + @Override + public void onStop() { + super.onStop(); + stopSearchTask(); + EventDistributor.getInstance().unregister(contentUpdate); + } + + @Override + public void onDetach() { + super.onDetach(); + stopSearchTask(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + searchAdapter = null; + viewCreated = false; + if (feedItemDialog != null) { + feedItemDialogSavedInstance = feedItemDialog.save(); + } + feedItemDialog = null; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ((ActionBarActivity) getActivity()).getSupportActionBar().setTitle(R.string.search_label); + viewCreated = true; + if (itemsLoaded) { + onFragmentLoaded(); + } + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + super.onListItemClick(l, v, position, id); + SearchResult result = (SearchResult) l.getAdapter().getItem(position); + FeedComponent comp = result.getComponent(); + if (comp.getClass() == Feed.class) { + ((MainActivity)getActivity()).loadFeedFragment(comp.getId()); + } else { + if (comp.getClass() == FeedItem.class) { + feedItemDialog = FeedItemDialog.newInstance(getActivity(), (FeedItem) comp, queue); + feedItemDialog.show(); + } + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + MenuItem item = menu.add(Menu.NONE, R.id.search_item, Menu.NONE, R.string.search_label); + MenuItemCompat.setShowAsAction(item, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); + final SearchView sv = new SearchView(getActivity()); + sv.setQueryHint(getString(R.string.search_hint)); + sv.setQuery(getArguments().getString(ARG_QUERY), false); + sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + getArguments().putString(ARG_QUERY, s); + itemsLoaded = false; + startSearchTask(); + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + MenuItemCompat.setActionView(item, sv); + } + } + + private final EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() { + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((arg & (EventDistributor.DOWNLOAD_QUEUED)) != 0) { + feedItemDialog.updateMenuAppearance(); + } + if ((arg & (EventDistributor.UNREAD_ITEMS_UPDATE + | EventDistributor.DOWNLOAD_HANDLED + | EventDistributor.QUEUE_UPDATE)) != 0) { + startSearchTask(); + } + } + }; + + private void onFragmentLoaded() { + if (searchAdapter == null) { + searchAdapter = new SearchlistAdapter(getActivity(), itemAccess); + setListAdapter(searchAdapter); + } + searchAdapter.notifyDataSetChanged(); + setListShown(true); + if (feedItemDialog != null && feedItemDialog.isShowing()) { + feedItemDialog.setQueue(queue); + for (SearchResult result : searchResults) { + FeedComponent comp = result.getComponent(); + if (comp.getClass() == FeedItem.class && ((FeedItem) comp).getId() == feedItemDialog.getItem().getId()) { + feedItemDialog.setItem((FeedItem) comp); + } + } + feedItemDialog.updateMenuAppearance(); + } else if (feedItemDialogSavedInstance != null) { + feedItemDialog = FeedItemDialog.newInstance(getActivity(), feedItemDialogSavedInstance); + } + } + + private final SearchlistAdapter.ItemAccess itemAccess = new SearchlistAdapter.ItemAccess() { + @Override + public int getCount() { + return (searchResults != null) ? searchResults.size() : 0; + } + + @Override + public SearchResult getItem(int position) { + return (searchResults != null) ? searchResults.get(position) : null; + } + }; + + private SearchTask searchTask; + + private void startSearchTask() { + if (searchTask != null) { + searchTask.cancel(true); + } + searchTask = new SearchTask(); + searchTask.execute(getArguments()); + } + + private void stopSearchTask() { + if (searchTask != null) { + searchTask.cancel(true); + } + } + + private class SearchTask extends AsyncTask<Bundle, Void, Object[]> { + @Override + protected Object[] doInBackground(Bundle... params) { + String query = params[0].getString(ARG_QUERY); + long feed = params[0].getLong(ARG_FEED); + Context context = getActivity(); + if (context != null) { + return new Object[]{FeedSearcher.performSearch(context, query, feed), + QueueAccess.IDListAccess(DBReader.getQueueIDList(context))}; + } else { + return null; + } + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + if (viewCreated && !itemsLoaded) { + setListShown(false); + } + } + + @Override + protected void onPostExecute(Object[] results) { + super.onPostExecute(results); + if (results != null) { + itemsLoaded = true; + searchResults = (List<SearchResult>) results[0]; + queue = (QueueAccess) results[1]; + if (viewCreated) { + onFragmentLoaded(); + } + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java new file mode 100644 index 000000000..ec8f69368 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java @@ -0,0 +1,131 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import android.app.Activity; +import android.content.res.Resources; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; + +/** + * Main navigation hub for gpodder.net podcast directory + */ +public class GpodnetMainFragment extends Fragment { + + private ViewPager pager; + private MainActivity activity; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + View root = inflater.inflate(R.layout.pager_fragment, container, false); + pager = (ViewPager) root.findViewById(R.id.pager); + GpodnetPagerAdapter pagerAdapter = new GpodnetPagerAdapter(getChildFragmentManager(), getResources()); + pager.setAdapter(pagerAdapter); + final ActionBar actionBar = activity.getMainActivtyActionBar(); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + ActionBar.TabListener tabListener = new ActionBar.TabListener() { + @Override + public void onTabSelected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) { + pager.setCurrentItem(tab.getPosition()); + } + + @Override + public void onTabUnselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) { + + } + + @Override + public void onTabReselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) { + + } + }; + actionBar.removeAllTabs(); + actionBar.addTab(actionBar.newTab() + .setText(R.string.gpodnet_taglist_header) + .setTabListener(tabListener)); + actionBar.addTab(actionBar.newTab() + .setText(R.string.gpodnet_toplist_header) + .setTabListener(tabListener)); + actionBar.setTitle(R.string.gpodnet_main_label); + + pager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + actionBar.setSelectedNavigationItem(position); + } + }); + return root; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + activity.getMainActivtyActionBar().removeAllTabs(); + activity.getMainActivtyActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + this.activity = (MainActivity) activity; + } + + public class GpodnetPagerAdapter extends FragmentPagerAdapter { + + + private static final int NUM_PAGES = 2; + private static final int POS_TAGS = 0; + private static final int POS_TOPLIST = 1; + private static final int POS_SUGGESTIONS = 2; + + Resources resources; + + public GpodnetPagerAdapter(FragmentManager fm, Resources resources) { + super(fm); + this.resources = resources; + } + + @Override + public Fragment getItem(int i) { + switch (i) { + case POS_TAGS: + return new TagListFragment(); + case POS_TOPLIST: + return new PodcastTopListFragment(); + case POS_SUGGESTIONS: + return new SuggestionListFragment(); + default: + return null; + } + } + + @Override + public CharSequence getPageTitle(int position) { + switch (position) { + case POS_TAGS: + return getString(R.string.gpodnet_taglist_header); + case POS_TOPLIST: + return getString(R.string.gpodnet_toplist_header); + case POS_SUGGESTIONS: + return getString(R.string.gpodnet_suggestions_header); + default: + return super.getPageTitle(position); + } + } + + @Override + public int getCount() { + return NUM_PAGES; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java new file mode 100644 index 000000000..1b4616207 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java @@ -0,0 +1,169 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import android.content.Context; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.widget.*; +import android.util.Log; +import android.view.*; +import android.widget.*; +import android.widget.SearchView; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.DefaultOnlineFeedViewActivity; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.activity.OnlineFeedViewActivity; +import de.danoeh.antennapod.adapter.gpodnet.PodcastListAdapter; +import de.danoeh.antennapod.fragment.SearchFragment; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +import java.util.List; + +/** + * Displays a list of GPodnetPodcast-Objects in a GridView + */ +public abstract class PodcastListFragment extends Fragment { + private static final String TAG = "PodcastListFragment"; + + private GridView gridView; + private ProgressBar progressBar; + private TextView txtvError; + private Button butRetry; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (!MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + final android.support.v7.widget.SearchView sv = new android.support.v7.widget.SearchView(getActivity()); + MenuItemUtils.addSearchItem(menu, sv); + sv.setQueryHint(getString(R.string.gpodnet_search_hint)); + sv.setOnQueryTextListener(new android.support.v7.widget.SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + sv.clearFocus(); + ((MainActivity) getActivity()).loadChildFragment(SearchListFragment.newInstance(s)); + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.gpodnet_podcast_list, container, false); + + gridView = (GridView) root.findViewById(R.id.gridView); + progressBar = (ProgressBar) root.findViewById(R.id.progressBar); + txtvError = (TextView) root.findViewById(R.id.txtvError); + butRetry = (Button) root.findViewById(R.id.butRetry); + + gridView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + onPodcastSelected((GpodnetPodcast) gridView.getAdapter().getItem(position)); + } + }); + butRetry.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + loadData(); + } + }); + + loadData(); + return root; + } + + protected void onPodcastSelected(GpodnetPodcast selection) { + if (BuildConfig.DEBUG) Log.d(TAG, "Selected podcast: " + selection.toString()); + Intent intent = new Intent(getActivity(), DefaultOnlineFeedViewActivity.class); + intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, selection.getUrl()); + intent.putExtra(DefaultOnlineFeedViewActivity.ARG_TITLE, getString(R.string.gpodnet_main_label)); + startActivity(intent); + } + + protected abstract List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException; + + protected final void loadData() { + AsyncTask<Void, Void, List<GpodnetPodcast>> loaderTask = new AsyncTask<Void, Void, List<GpodnetPodcast>>() { + volatile Exception exception = null; + + @Override + protected List<GpodnetPodcast> doInBackground(Void... params) { + GpodnetService service = null; + try { + service = new GpodnetService(); + return loadPodcastData(service); + } catch (GpodnetServiceException e) { + exception = e; + e.printStackTrace(); + return null; + } finally { + if (service != null) { + service.shutdown(); + } + } + } + + @Override + protected void onPostExecute(List<GpodnetPodcast> gpodnetPodcasts) { + super.onPostExecute(gpodnetPodcasts); + final Context context = getActivity(); + if (context != null && gpodnetPodcasts != null && gpodnetPodcasts.size() > 0) { + PodcastListAdapter listAdapter = new PodcastListAdapter(context, 0, gpodnetPodcasts); + gridView.setAdapter(listAdapter); + listAdapter.notifyDataSetChanged(); + + progressBar.setVisibility(View.GONE); + gridView.setVisibility(View.VISIBLE); + txtvError.setVisibility(View.GONE); + butRetry.setVisibility(View.GONE); + } else if (context != null && gpodnetPodcasts != null) { + gridView.setVisibility(View.GONE); + progressBar.setVisibility(View.GONE); + txtvError.setText(getString(R.string.search_status_no_results)); + txtvError.setVisibility(View.VISIBLE); + butRetry.setVisibility(View.GONE); + } else if (context != null) { + gridView.setVisibility(View.GONE); + progressBar.setVisibility(View.GONE); + txtvError.setText(getString(R.string.error_msg_prefix) + exception.getMessage()); + txtvError.setVisibility(View.VISIBLE); + butRetry.setVisibility(View.VISIBLE); + } + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + gridView.setVisibility(View.GONE); + progressBar.setVisibility(View.VISIBLE); + txtvError.setVisibility(View.GONE); + butRetry.setVisibility(View.GONE); + } + }; + + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + loaderTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + loaderTask.execute(); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java new file mode 100644 index 000000000..5717a74e7 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; + +import java.util.List; + +/** + * + */ +public class PodcastTopListFragment extends PodcastListFragment { + private static final String TAG = "PodcastTopListFragment"; + private static final int PODCAST_COUNT = 50; + + @Override + protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException { + return service.getPodcastToplist(PODCAST_COUNT); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java new file mode 100644 index 000000000..801024787 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java @@ -0,0 +1,80 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import android.os.Bundle; +import android.support.v7.widget.SearchView; +import android.view.Menu; +import android.view.MenuInflater; + +import org.apache.commons.lang3.Validate; + +import java.util.List; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +/** + * Performs a search on the gpodder.net directory and displays the results. + */ +public class SearchListFragment extends PodcastListFragment { + private static final String ARG_QUERY = "query"; + + private String query; + + public static SearchListFragment newInstance(String query) { + SearchListFragment fragment = new SearchListFragment(); + Bundle args = new Bundle(); + args.putString(ARG_QUERY, query); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null && getArguments().containsKey(ARG_QUERY)) { + this.query = getArguments().getString(ARG_QUERY); + } else { + this.query = ""; + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + final SearchView sv = new SearchView(getActivity()); + if (!MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + MenuItemUtils.addSearchItem(menu, sv); + sv.setQueryHint(getString(R.string.gpodnet_search_hint)); + sv.setQuery(query, false); + sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + sv.clearFocus(); + changeQuery(s); + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + } + } + + @Override + protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException { + return service.searchPodcasts(query, 0); + } + + public void changeQuery(String query) { + Validate.notNull(query); + + this.query = query; + loadData(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java new file mode 100644 index 000000000..45fe25580 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.preferences.GpodnetPreferences; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays suggestions from gpodder.net + */ +public class SuggestionListFragment extends PodcastListFragment { + private static final int SUGGESTIONS_COUNT = 50; + + @Override + protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException { + if (GpodnetPreferences.loggedIn()) { + service.authenticate(GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); + return service.getSuggestions(SUGGESTIONS_COUNT); + } else { + return new ArrayList<GpodnetPodcast>(); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java new file mode 100644 index 000000000..204dda992 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java @@ -0,0 +1,50 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import android.os.Bundle; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.gpoddernet.model.GpodnetTag; + +import java.util.List; + +/** + * Shows all podcasts from gpodder.net that belong to a specific tag. + * Use the newInstance method of this class to create a new TagFragment. + */ +public class TagFragment extends PodcastListFragment { + + private static final String TAG = "TagFragment"; + private static final int PODCAST_COUNT = 50; + + private GpodnetTag tag; + + public static TagFragment newInstance(String tagName) { + Validate.notNull(tagName); + TagFragment fragment = new TagFragment(); + Bundle args = new Bundle(); + args.putString("tag", tagName); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Bundle args = getArguments(); + Validate.isTrue(args != null && args.getString("tag") != null, "args invalid"); + + tag = new GpodnetTag(args.getString("tag")); + ((MainActivity) getActivity()).getMainActivtyActionBar().setTitle(tag.getName()); + } + + @Override + protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException { + return service.getPodcastsForTag(tag, PODCAST_COUNT); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java new file mode 100644 index 000000000..a7e1033df --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java @@ -0,0 +1,146 @@ +package de.danoeh.antennapod.fragment.gpodnet; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.ListFragment; +import android.support.v7.widget.SearchView; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetTag; +import de.danoeh.antennapod.util.menuhandler.MenuItemUtils; +import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity; + +public class TagListFragment extends ListFragment { + private static final String TAG = "TagListFragment"; + private static final int COUNT = 50; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (!MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) { + final SearchView sv = new SearchView(getActivity()); + MenuItemUtils.addSearchItem(menu, sv); + sv.setQueryHint(getString(R.string.gpodnet_search_hint)); + sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + Activity activity = getActivity(); + if (activity != null) { + sv.clearFocus(); + ((MainActivity) activity).loadChildFragment(SearchListFragment.newInstance(s)); + } + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return false; + } + }); + } + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + String selectedTag = (String) getListAdapter().getItem(position); + MainActivity activity = (MainActivity) getActivity(); + activity.loadChildFragment(TagFragment.newInstance(selectedTag)); + } + }); + + startLoadTask(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + cancelLoadTask(); + } + + private AsyncTask<Void, Void, List<GpodnetTag>> loadTask; + + private void cancelLoadTask() { + if (loadTask != null && !loadTask.isCancelled()) { + loadTask.cancel(true); + } + } + + private void startLoadTask() { + cancelLoadTask(); + loadTask = new AsyncTask<Void, Void, List<GpodnetTag>>() { + private Exception exception; + + @Override + protected List<GpodnetTag> doInBackground(Void... params) { + GpodnetService service = new GpodnetService(); + try { + return service.getTopTags(COUNT); + } catch (GpodnetServiceException e) { + e.printStackTrace(); + exception = e; + return null; + } finally { + service.shutdown(); + } + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + setListShown(false); + } + + @Override + protected void onPostExecute(List<GpodnetTag> gpodnetTags) { + super.onPostExecute(gpodnetTags); + final Context context = getActivity(); + if (context != null) { + if (gpodnetTags != null) { + List<String> tagNames = new ArrayList<String>(); + for (GpodnetTag tag : gpodnetTags) { + tagNames.add(tag.getName()); + } + setListAdapter(new ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, tagNames)); + } else if (exception != null) { + TextView txtvError = new TextView(getActivity()); + txtvError.setText(exception.getMessage()); + getListView().setEmptyView(txtvError); + } + setListShown(true); + + } + } + }; + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + loadTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + loadTask.execute(); + } + } +} + diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java new file mode 100644 index 000000000..038b2a367 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java @@ -0,0 +1,718 @@ +package de.danoeh.antennapod.gpoddernet; + +import org.apache.commons.lang3.Validate; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.auth.AuthenticationException; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.auth.BasicScheme; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +import de.danoeh.antennapod.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.gpoddernet.model.GpodnetSubscriptionChange; +import de.danoeh.antennapod.gpoddernet.model.GpodnetTag; +import de.danoeh.antennapod.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.preferences.GpodnetPreferences; +import de.danoeh.antennapod.service.download.AntennapodHttpClient; + +/** + * Communicates with the gpodder.net service. + */ +public class GpodnetService { + + private static final String BASE_SCHEME = "https"; + + public static final String DEFAULT_BASE_HOST = "gpodder.net"; + private final String BASE_HOST; + + private final HttpClient httpClient; + + public GpodnetService() { + httpClient = AntennapodHttpClient.getHttpClient(); + BASE_HOST = GpodnetPreferences.getHostname(); + } + + /** + * Returns the [count] most used tags. + */ + public List<GpodnetTag> getTopTags(int count) + throws GpodnetServiceException { + URI uri; + try { + uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/api/2/tags/%d.json", count), null); + } catch (URISyntaxException e1) { + e1.printStackTrace(); + throw new IllegalStateException(e1); + } + + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + try { + JSONArray jsonTagList = new JSONArray(response); + List<GpodnetTag> tagList = new ArrayList<GpodnetTag>( + jsonTagList.length()); + for (int i = 0; i < jsonTagList.length(); i++) { + JSONObject jObj = jsonTagList.getJSONObject(i); + String name = jObj.getString("tag"); + int usage = jObj.getInt("usage"); + tagList.add(new GpodnetTag(name, usage)); + } + return tagList; + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + /** + * Returns the [count] most subscribed podcasts for the given tag. + * + * @throws IllegalArgumentException if tag is null + */ + public List<GpodnetPodcast> getPodcastsForTag(GpodnetTag tag, int count) + throws GpodnetServiceException { + Validate.notNull(tag); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/api/2/tag/%s/%d.json", tag.getName(), count), null); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + + JSONArray jsonArray = new JSONArray(response); + return readPodcastListFromJSONArray(jsonArray); + + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + + } + } + + /** + * Returns the toplist of podcast. + * + * @param count of elements that should be returned. Must be in range 1..100. + * @throws IllegalArgumentException if count is out of range. + */ + public List<GpodnetPodcast> getPodcastToplist(int count) + throws GpodnetServiceException { + Validate.isTrue(count >= 1 && count <= 100, "Count must be in range 1..100"); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/toplist/%d.json", count), null); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + + JSONArray jsonArray = new JSONArray(response); + return readPodcastListFromJSONArray(jsonArray); + + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + + } + } + + /** + * Returns a list of suggested podcasts for the user that is currently + * logged in. + * <p/> + * This method requires authentication. + * + * @param count The + * number of elements that should be returned. Must be in range + * 1..100. + * @throws IllegalArgumentException if count is out of range. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public List<GpodnetPodcast> getSuggestions(int count) throws GpodnetServiceException { + Validate.isTrue(count >= 1 && count <= 100, "Count must be in range 1..100"); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/suggestions/%d.json", count), null); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + + JSONArray jsonArray = new JSONArray(response); + return readPodcastListFromJSONArray(jsonArray); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + /** + * Searches the podcast directory for a given string. + * + * @param query The search query + * @param scaledLogoSize The size of the logos that are returned by the search query. + * Must be in range 1..256. If the value is out of range, the + * default value defined by the gpodder.net API will be used. + */ + public List<GpodnetPodcast> searchPodcasts(String query, int scaledLogoSize) + throws GpodnetServiceException { + String parameters = (scaledLogoSize > 0 && scaledLogoSize <= 256) ? String + .format("q=%s&scale_logo=%d", query, scaledLogoSize) : String + .format("q=%s", query); + try { + URI uri = new URI(BASE_SCHEME, null, BASE_HOST, -1, "/search.json", + parameters, null); + System.out.println(uri.toASCIIString()); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + + JSONArray jsonArray = new JSONArray(response); + return readPodcastListFromJSONArray(jsonArray); + + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + + } + } + + /** + * Returns all devices of a given user. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @throws IllegalArgumentException If username is null. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public List<GpodnetDevice> getDevices(String username) + throws GpodnetServiceException { + Validate.notNull(username); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/api/2/devices/%s.json", username), null); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + JSONArray devicesArray = new JSONArray(response); + List<GpodnetDevice> result = readDeviceListFromJSONArray(devicesArray); + + return result; + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + /** + * Configures the device of a given user. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @param deviceId The ID of the device that should be configured. + * @throws IllegalArgumentException If username or deviceId is null. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public void configureDevice(String username, String deviceId, + String caption, GpodnetDevice.DeviceType type) + throws GpodnetServiceException { + Validate.notNull(username); + Validate.notNull(deviceId); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/api/2/devices/%s/%s.json", username, deviceId), null); + HttpPost request = new HttpPost(uri); + if (caption != null || type != null) { + JSONObject jsonContent = new JSONObject(); + if (caption != null) { + jsonContent.put("caption", caption); + } + if (type != null) { + jsonContent.put("type", type.toString()); + } + StringEntity strEntity = new StringEntity( + jsonContent.toString(), "UTF-8"); + strEntity.setContentType("application/json"); + request.setEntity(strEntity); + } + executeRequest(request); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalArgumentException(e); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + /** + * Returns the subscriptions of a specific device. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @param deviceId The ID of the device whose subscriptions should be returned. + * @return A list of subscriptions in OPML format. + * @throws IllegalArgumentException If username or deviceId is null. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public String getSubscriptionsOfDevice(String username, String deviceId) + throws GpodnetServiceException { + Validate.notNull(username); + Validate.notNull(deviceId); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/subscriptions/%s/%s.opml", username, deviceId), null); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + return response; + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalArgumentException(e); + } + } + + /** + * Returns all subscriptions of a specific user. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @return A list of subscriptions in OPML format. + * @throws IllegalArgumentException If username is null. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public String getSubscriptionsOfUser(String username) + throws GpodnetServiceException { + Validate.notNull(username); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/subscriptions/%s.opml", username), null); + HttpGet request = new HttpGet(uri); + String response = executeRequest(request); + return response; + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalArgumentException(e); + } + } + + /** + * Uploads the subscriptions of a specific device. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @param deviceId The ID of the device whose subscriptions should be updated. + * @param subscriptions A list of feed URLs containing all subscriptions of the + * device. + * @throws IllegalArgumentException If username, deviceId or subscriptions is null. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public void uploadSubscriptions(String username, String deviceId, + List<String> subscriptions) throws GpodnetServiceException { + if (username == null || deviceId == null || subscriptions == null) { + throw new IllegalArgumentException( + "Username, device ID and subscriptions must not be null"); + } + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/subscriptions/%s/%s.txt", username, deviceId), null); + HttpPut request = new HttpPut(uri); + StringBuilder builder = new StringBuilder(); + for (String s : subscriptions) { + builder.append(s); + builder.append("\n"); + } + StringEntity entity = new StringEntity(builder.toString(), "UTF-8"); + request.setEntity(entity); + + executeRequest(request); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } + } + + /** + * Updates the subscription list of a specific device. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @param deviceId The ID of the device whose subscriptions should be updated. + * @param added Collection of feed URLs of added feeds. This Collection MUST NOT contain any duplicates + * @param removed Collection of feed URLs of removed feeds. This Collection MUST NOT contain any duplicates + * @return a GpodnetUploadChangesResponse. See {@link de.danoeh.antennapod.gpoddernet.model.GpodnetUploadChangesResponse} + * for details. + * @throws java.lang.IllegalArgumentException if username, deviceId, added or removed is null. + * @throws de.danoeh.antennapod.gpoddernet.GpodnetServiceException if added or removed contain duplicates or if there + * is an authentication error. + */ + public GpodnetUploadChangesResponse uploadChanges(String username, String deviceId, Collection<String> added, + Collection<String> removed) throws GpodnetServiceException { + Validate.notNull(username); + Validate.notNull(deviceId); + Validate.notNull(added); + Validate.notNull(removed); + + try { + URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/api/2/subscriptions/%s/%s.json", username, deviceId), null); + + final JSONObject requestObject = new JSONObject(); + requestObject.put("add", new JSONArray(added)); + requestObject.put("remove", new JSONArray(removed)); + + HttpPost request = new HttpPost(uri); + StringEntity entity = new StringEntity(requestObject.toString(), "UTF-8"); + request.setEntity(entity); + + final String response = executeRequest(request); + return GpodnetUploadChangesResponse.fromJSONObject(response); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } + + } + + /** + * Returns all subscription changes of a specific device. + * <p/> + * This method requires authentication. + * + * @param username The username. Must be the same user as the one which is + * currently logged in. + * @param deviceId The ID of the device whose subscription changes should be + * downloaded. + * @param timestamp A timestamp that can be used to receive all changes since a + * specific point in time. + * @throws IllegalArgumentException If username or deviceId is null. + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public GpodnetSubscriptionChange getSubscriptionChanges(String username, + String deviceId, long timestamp) throws GpodnetServiceException { + Validate.notNull(username); + Validate.notNull(deviceId); + + String params = String.format("since=%d", timestamp); + String path = String.format("/api/2/subscriptions/%s/%s.json", + username, deviceId); + try { + URI uri = new URI(BASE_SCHEME, null, BASE_HOST, -1, path, params, + null); + HttpGet request = new HttpGet(uri); + + String response = executeRequest(request); + JSONObject changes = new JSONObject(response); + return readSubscriptionChangesFromJSONObject(changes); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new IllegalStateException(e); + } catch (JSONException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + + } + + /** + * Logs in a specific user. This method must be called if any of the methods + * that require authentication is used. + * + * @throws IllegalArgumentException If username or password is null. + */ + public void authenticate(String username, String password) + throws GpodnetServiceException { + Validate.notNull(username); + Validate.notNull(password); + + URI uri; + try { + uri = new URI(BASE_SCHEME, BASE_HOST, String.format( + "/api/2/auth/%s/login.json", username), null); + } catch (URISyntaxException e) { + e.printStackTrace(); + throw new GpodnetServiceException(); + } + HttpPost request = new HttpPost(uri); + executeRequestWithAuthentication(request, username, password); + } + + /** + * Shuts down the GpodnetService's HTTP client. The service will be shut down in a separate thread to avoid + * NetworkOnMainThreadExceptions. + */ + public void shutdown() { + new Thread() { + @Override + public void run() { + AntennapodHttpClient.cleanup(); + } + }.start(); + } + + private String executeRequest(HttpRequestBase request) + throws GpodnetServiceException { + Validate.notNull(request); + + String responseString = null; + HttpResponse response = null; + try { + response = httpClient.execute(request); + checkStatusCode(response); + responseString = getStringFromEntity(response.getEntity()); + } catch (ClientProtocolException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (IOException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } finally { + if (response != null) { + try { + response.getEntity().consumeContent(); + } catch (IOException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + } + return responseString; + } + + private String executeRequestWithAuthentication(HttpRequestBase request, + String username, String password) throws GpodnetServiceException { + if (request == null || username == null || password == null) { + throw new IllegalArgumentException( + "request and credentials must not be null"); + } + String result = null; + HttpResponse response = null; + try { + Header auth = new BasicScheme().authenticate( + new UsernamePasswordCredentials(username, password), + request); + request.addHeader(auth); + response = httpClient.execute(request); + checkStatusCode(response); + result = getStringFromEntity(response.getEntity()); + } catch (ClientProtocolException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (IOException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } catch (AuthenticationException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } finally { + if (response != null) { + try { + response.getEntity().consumeContent(); + } catch (IOException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + } + return result; + } + + private String getStringFromEntity(HttpEntity entity) + throws GpodnetServiceException { + Validate.notNull(entity); + + ByteArrayOutputStream outputStream; + int contentLength = (int) entity.getContentLength(); + if (contentLength > 0) { + outputStream = new ByteArrayOutputStream(contentLength); + } else { + outputStream = new ByteArrayOutputStream(); + } + try { + byte[] buffer = new byte[8 * 1024]; + InputStream in = entity.getContent(); + int count; + while ((count = in.read(buffer)) > 0) { + outputStream.write(buffer, 0, count); + } + } catch (IOException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + // System.out.println(outputStream.toString()); + return outputStream.toString(); + } + + private void checkStatusCode(HttpResponse response) + throws GpodnetServiceException { + Validate.notNull(response); + int responseCode = response.getStatusLine().getStatusCode(); + if (responseCode != HttpStatus.SC_OK) { + if (responseCode == HttpStatus.SC_UNAUTHORIZED) { + throw new GpodnetServiceAuthenticationException("Wrong username or password"); + } else { + throw new GpodnetServiceBadStatusCodeException( + "Bad response code: " + responseCode, responseCode); + } + } + } + + private List<GpodnetPodcast> readPodcastListFromJSONArray(JSONArray array) + throws JSONException { + Validate.notNull(array); + + List<GpodnetPodcast> result = new ArrayList<GpodnetPodcast>( + array.length()); + for (int i = 0; i < array.length(); i++) { + result.add(readPodcastFromJSONObject(array.getJSONObject(i))); + } + return result; + + } + + private GpodnetPodcast readPodcastFromJSONObject(JSONObject object) + throws JSONException { + String url = object.getString("url"); + + String title; + Object titleObj = object.opt("title"); + if (titleObj != null && titleObj instanceof String) { + title = (String) titleObj; + } else { + title = url; + } + + String description; + Object descriptionObj = object.opt("description"); + if (descriptionObj != null && descriptionObj instanceof String) { + description = (String) descriptionObj; + } else { + description = ""; + } + + int subscribers = object.getInt("subscribers"); + + Object logoUrlObj = object.opt("logo_url"); + String logoUrl = (logoUrlObj instanceof String) ? (String) logoUrlObj + : null; + if (logoUrl == null) { + Object scaledLogoUrl = object.opt("scaled_logo_url"); + if (scaledLogoUrl != null && scaledLogoUrl instanceof String) { + logoUrl = (String) scaledLogoUrl; + } + } + + String website = null; + Object websiteObj = object.opt("website"); + if (websiteObj != null && websiteObj instanceof String) { + website = (String) websiteObj; + } + String mygpoLink = object.getString("mygpo_link"); + return new GpodnetPodcast(url, title, description, subscribers, + logoUrl, website, mygpoLink); + } + + private List<GpodnetDevice> readDeviceListFromJSONArray(JSONArray array) + throws JSONException { + Validate.notNull(array); + + List<GpodnetDevice> result = new ArrayList<GpodnetDevice>( + array.length()); + for (int i = 0; i < array.length(); i++) { + result.add(readDeviceFromJSONObject(array.getJSONObject(i))); + } + return result; + } + + private GpodnetDevice readDeviceFromJSONObject(JSONObject object) + throws JSONException { + String id = object.getString("id"); + String caption = object.getString("caption"); + String type = object.getString("type"); + int subscriptions = object.getInt("subscriptions"); + return new GpodnetDevice(id, caption, type, subscriptions); + } + + private GpodnetSubscriptionChange readSubscriptionChangesFromJSONObject( + JSONObject object) throws JSONException { + Validate.notNull(object); + + List<String> added = new LinkedList<String>(); + JSONArray jsonAdded = object.getJSONArray("add"); + for (int i = 0; i < jsonAdded.length(); i++) { + added.add(jsonAdded.getString(i)); + } + + List<String> removed = new LinkedList<String>(); + JSONArray jsonRemoved = object.getJSONArray("remove"); + for (int i = 0; i < jsonRemoved.length(); i++) { + removed.add(jsonRemoved.getString(i)); + } + + long timestamp = object.getLong("timestamp"); + return new GpodnetSubscriptionChange(added, removed, timestamp); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java new file mode 100644 index 000000000..3b0140826 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.gpoddernet; + +public class GpodnetServiceAuthenticationException extends GpodnetServiceException { + + public GpodnetServiceAuthenticationException() { + super(); + } + + public GpodnetServiceAuthenticationException(String message, Throwable cause) { + super(message, cause); + } + + public GpodnetServiceAuthenticationException(String message) { + super(message); + } + + public GpodnetServiceAuthenticationException(Throwable cause) { + super(cause); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java new file mode 100644 index 000000000..a32e9357b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java @@ -0,0 +1,12 @@ +package de.danoeh.antennapod.gpoddernet; + +public class GpodnetServiceBadStatusCodeException extends GpodnetServiceException { + int statusCode; + + public GpodnetServiceBadStatusCodeException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java new file mode 100644 index 000000000..bdb394454 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.gpoddernet; + +public class GpodnetServiceException extends Exception { + + public GpodnetServiceException() { + } + + public GpodnetServiceException(String message) { + super(message); + } + + public GpodnetServiceException(Throwable cause) { + super(cause); + } + + public GpodnetServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java new file mode 100644 index 000000000..86a2171fa --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java @@ -0,0 +1,72 @@ +package de.danoeh.antennapod.gpoddernet.model; + +import org.apache.commons.lang3.Validate; + +public class GpodnetDevice { + + private String id; + private String caption; + private DeviceType type; + private int subscriptions; + + public GpodnetDevice(String id, String caption, String type, + int subscriptions) { + Validate.notNull(id); + + this.id = id; + this.caption = caption; + this.type = DeviceType.fromString(type); + this.subscriptions = subscriptions; + } + + @Override + public String toString() { + return "GpodnetDevice [id=" + id + ", caption=" + caption + ", type=" + + type + ", subscriptions=" + subscriptions + "]"; + } + + public static enum DeviceType { + DESKTOP, LAPTOP, MOBILE, SERVER, OTHER; + + static DeviceType fromString(String s) { + if (s == null) { + return OTHER; + } + + if (s.equals("desktop")) { + return DESKTOP; + } else if (s.equals("laptop")) { + return LAPTOP; + } else if (s.equals("mobile")) { + return MOBILE; + } else if (s.equals("server")) { + return SERVER; + } else { + return OTHER; + } + } + + @Override + public String toString() { + return super.toString().toLowerCase(); + } + + } + + public String getId() { + return id; + } + + public String getCaption() { + return caption; + } + + public DeviceType getType() { + return type; + } + + public int getSubscriptions() { + return subscriptions; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java new file mode 100644 index 000000000..b002035c9 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java @@ -0,0 +1,65 @@ +package de.danoeh.antennapod.gpoddernet.model; + +import org.apache.commons.lang3.Validate; + +public class GpodnetPodcast { + private String url; + private String title; + private String description; + private int subscribers; + private String logoUrl; + private String website; + private String mygpoLink; + + public GpodnetPodcast(String url, String title, String description, + int subscribers, String logoUrl, String website, String mygpoLink) { + Validate.notNull(url); + Validate.notNull(title); + Validate.notNull(description); + + this.url = url; + this.title = title; + this.description = description; + this.subscribers = subscribers; + this.logoUrl = logoUrl; + this.website = website; + this.mygpoLink = mygpoLink; + } + + @Override + public String toString() { + return "GpodnetPodcast [url=" + url + ", title=" + title + + ", description=" + description + ", subscribers=" + + subscribers + ", logoUrl=" + logoUrl + ", website=" + website + + ", mygpoLink=" + mygpoLink + "]"; + } + + public String getUrl() { + return url; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public int getSubscribers() { + return subscribers; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getWebsite() { + return website; + } + + public String getMygpoLink() { + return mygpoLink; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java new file mode 100644 index 000000000..a4617118d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java @@ -0,0 +1,41 @@ +package de.danoeh.antennapod.gpoddernet.model; + +import org.apache.commons.lang3.Validate; + +import java.util.List; + +public class GpodnetSubscriptionChange { + private List<String> added; + private List<String> removed; + private long timestamp; + + public GpodnetSubscriptionChange(List<String> added, List<String> removed, + long timestamp) { + Validate.notNull(added); + Validate.notNull(removed); + + this.added = added; + this.removed = removed; + this.timestamp = timestamp; + } + + @Override + public String toString() { + return "GpodnetSubscriptionChange [added=" + added.toString() + + ", removed=" + removed.toString() + ", timestamp=" + + timestamp + "]"; + } + + public List<String> getAdded() { + return added; + } + + public List<String> getRemoved() { + return removed; + } + + public long getTimestamp() { + return timestamp; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java new file mode 100644 index 000000000..80b84095e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.gpoddernet.model; + +import org.apache.commons.lang3.Validate; + +import java.util.Comparator; + +public class GpodnetTag { + + private String name; + private int usage; + + public GpodnetTag(String name, int usage) { + Validate.notNull(name); + + this.name = name; + this.usage = usage; + } + + public GpodnetTag(String name) { + super(); + this.name = name; + } + + @Override + public String toString() { + return "GpodnetTag [name=" + name + ", usage=" + usage + "]"; + } + + public String getName() { + return name; + } + + public int getUsage() { + return usage; + } + + public static class UsageComparator implements Comparator<GpodnetTag> { + + @Override + public int compare(GpodnetTag o1, GpodnetTag o2) { + return o1.usage - o2.usage; + } + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java new file mode 100644 index 000000000..fee8c7d28 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java @@ -0,0 +1,56 @@ +package de.danoeh.antennapod.gpoddernet.model; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +/** + * Object returned by {@link de.danoeh.antennapod.gpoddernet.GpodnetService} in uploadChanges method. + */ +public class GpodnetUploadChangesResponse { + + /** + * timestamp/ID that can be used for requesting changes since this upload. + */ + public final long timestamp; + + /** + * URLs that should be updated. The key of the map is the original URL, the value of the map + * is the sanitized URL. + */ + public final Map<String, String> updatedUrls; + + public GpodnetUploadChangesResponse(long timestamp, Map<String, String> updatedUrls) { + this.timestamp = timestamp; + this.updatedUrls = updatedUrls; + } + + /** + * Creates a new GpodnetUploadChangesResponse-object from a JSON object that was + * returned by an uploadChanges call. + * + * @throws org.json.JSONException If the method could not parse the JSONObject. + */ + public static GpodnetUploadChangesResponse fromJSONObject(String objectString) throws JSONException { + final JSONObject object = new JSONObject(objectString); + final long timestamp = object.getLong("timestamp"); + Map<String, String> updatedUrls = new HashMap<String, String>(); + JSONArray urls = object.getJSONArray("update_urls"); + for (int i = 0; i < urls.length(); i++) { + JSONArray urlPair = urls.getJSONArray(i); + updatedUrls.put(urlPair.getString(0), urlPair.getString(1)); + } + return new GpodnetUploadChangesResponse(timestamp, updatedUrls); + } + + @Override + public String toString() { + return "GpodnetUploadChangesResponse{" + + "timestamp=" + timestamp + + ", updatedUrls=" + updatedUrls + + '}'; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java new file mode 100644 index 000000000..4cb563c04 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.opml; + +/** Represents a single feed in an OPML file. */ +public class OpmlElement { + private String text; + private String xmlUrl; + private String htmlUrl; + private String type; + + public OpmlElement() { + + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getXmlUrl() { + return xmlUrl; + } + + public void setXmlUrl(String xmlUrl) { + this.xmlUrl = xmlUrl; + } + + public String getHtmlUrl() { + return htmlUrl; + } + + public void setHtmlUrl(String htmlUrl) { + this.htmlUrl = htmlUrl; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java new file mode 100644 index 000000000..19a980dee --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java @@ -0,0 +1,87 @@ +package de.danoeh.antennapod.opml; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; + +/** Reads OPML documents. */ +public class OpmlReader { + private static final String TAG = "OpmlReader"; + + // ATTRIBUTES + private boolean isInOpml = false; + private ArrayList<OpmlElement> elementList; + + /** + * Reads an Opml document and returns a list of all OPML elements it can + * find + * + * @throws IOException + * @throws XmlPullParserException + */ + public ArrayList<OpmlElement> readDocument(Reader reader) + throws XmlPullParserException, IOException { + elementList = new ArrayList<OpmlElement>(); + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + XmlPullParser xpp = factory.newPullParser(); + xpp.setInput(reader); + int eventType = xpp.getEventType(); + + while (eventType != XmlPullParser.END_DOCUMENT) { + switch (eventType) { + case XmlPullParser.START_DOCUMENT: + if (BuildConfig.DEBUG) + Log.d(TAG, "Reached beginning of document"); + break; + case XmlPullParser.START_TAG: + if (xpp.getName().equals(OpmlSymbols.OPML)) { + isInOpml = true; + if (BuildConfig.DEBUG) + Log.d(TAG, "Reached beginning of OPML tree."); + } else if (isInOpml && xpp.getName().equals(OpmlSymbols.OUTLINE)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Found new Opml element"); + OpmlElement element = new OpmlElement(); + + final String title = xpp.getAttributeValue(null, OpmlSymbols.TITLE); + if (title != null) { + Log.i(TAG, "Using title: " + title); + element.setText(title); + } else { + Log.i(TAG, "Title not found, using text"); + element.setText(xpp.getAttributeValue(null, OpmlSymbols.TEXT)); + } + element.setXmlUrl(xpp.getAttributeValue(null, OpmlSymbols.XMLURL)); + element.setHtmlUrl(xpp.getAttributeValue(null, OpmlSymbols.HTMLURL)); + element.setType(xpp.getAttributeValue(null, OpmlSymbols.TYPE)); + if (element.getXmlUrl() != null) { + if (element.getText() == null) { + Log.i(TAG, "Opml element has no text attribute."); + element.setText(element.getXmlUrl()); + } + elementList.add(element); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Skipping element because of missing xml url"); + } + } + break; + } + eventType = xpp.next(); + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "Parsing finished."); + + return elementList; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java new file mode 100644 index 000000000..4b0b7316a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.opml; + +/** Contains symbols for reading and writing OPML documents. */ +public final class OpmlSymbols { + + public static final String OPML = "opml"; + public static final String BODY = "body"; + public static final String OUTLINE = "outline"; + public static final String TEXT = "text"; + public static final String XMLURL = "xmlUrl"; + public static final String HTMLURL = "htmlUrl"; + public static final String TYPE = "type"; + public static final String VERSION = "version"; + public static final String HEAD = "head"; + public static final String TITLE = "title"; + + private OpmlSymbols() { + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java new file mode 100644 index 000000000..405a5e35a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java @@ -0,0 +1,65 @@ +package de.danoeh.antennapod.opml; + +import android.util.Log; +import android.util.Xml; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Feed; +import org.xmlpull.v1.XmlSerializer; + +import java.io.IOException; +import java.io.Writer; +import java.util.List; + +/** Writes OPML documents. */ +public class OpmlWriter { + private static final String TAG = "OpmlWriter"; + private static final String ENCODING = "UTF-8"; + private static final String OPML_VERSION = "2.0"; + private static final String OPML_TITLE = "AntennaPod Subscriptions"; + + /** + * Takes a list of feeds and a writer and writes those into an OPML + * document. + * + * @throws IOException + * @throws IllegalStateException + * @throws IllegalArgumentException + */ + public void writeDocument(List<Feed> feeds, Writer writer) + throws IllegalArgumentException, IllegalStateException, IOException { + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting to write document"); + XmlSerializer xs = Xml.newSerializer(); + xs.setOutput(writer); + + xs.startDocument(ENCODING, false); + xs.startTag(null, OpmlSymbols.OPML); + xs.attribute(null, OpmlSymbols.VERSION, OPML_VERSION); + + xs.startTag(null, OpmlSymbols.HEAD); + xs.startTag(null, OpmlSymbols.TITLE); + xs.text(OPML_TITLE); + xs.endTag(null, OpmlSymbols.TITLE); + xs.endTag(null, OpmlSymbols.HEAD); + + xs.startTag(null, OpmlSymbols.BODY); + for (Feed feed : feeds) { + xs.startTag(null, OpmlSymbols.OUTLINE); + xs.attribute(null, OpmlSymbols.TEXT, feed.getTitle()); + xs.attribute(null, OpmlSymbols.TITLE, feed.getTitle()); + if (feed.getType() != null) { + xs.attribute(null, OpmlSymbols.TYPE, feed.getType()); + } + xs.attribute(null, OpmlSymbols.XMLURL, feed.getDownload_url()); + if (feed.getLink() != null) { + xs.attribute(null, OpmlSymbols.HTMLURL, feed.getLink()); + } + xs.endTag(null, OpmlSymbols.OUTLINE); + } + xs.endTag(null, OpmlSymbols.BODY); + xs.endTag(null, OpmlSymbols.OPML); + xs.endDocument(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Finished writing document"); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java b/app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java new file mode 100644 index 000000000..bdfe297a6 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java @@ -0,0 +1,246 @@ +package de.danoeh.antennapod.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.service.GpodnetSyncService; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Manages preferences for accessing gpodder.net service + */ +public class GpodnetPreferences { + + private static final String TAG = "GpodnetPreferences"; + + private static final String PREF_NAME = "gpodder.net"; + public static final String PREF_GPODNET_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username"; + public static final String PREF_GPODNET_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password"; + public static final String PREF_GPODNET_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID"; + public static final String PREF_GPODNET_HOSTNAME = "prefGpodnetHostname"; + + + public static final String PREF_LAST_SYNC_TIMESTAMP = "de.danoeh.antennapod.preferences.gpoddernet.last_sync_timestamp"; + public static final String PREF_SYNC_ADDED = "de.danoeh.antennapod.preferences.gpoddernet.sync_added"; + public static final String PREF_SYNC_REMOVED = "de.danoeh.antennapod.preferences.gpoddernet.sync_removed"; + + private static String username; + private static String password; + private static String deviceID; + private static String hostname; + + private static ReentrantLock feedListLock = new ReentrantLock(); + private static Set<String> addedFeeds; + private static Set<String> removedFeeds; + + /** + * Last value returned by getSubscriptionChanges call. Will be used for all subsequent calls of getSubscriptionChanges. + */ + private static long lastSyncTimestamp; + + private static boolean preferencesLoaded = false; + + private static SharedPreferences getPreferences() { + return PodcastApp.getInstance().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + } + + private static synchronized void ensurePreferencesLoaded() { + if (!preferencesLoaded) { + SharedPreferences prefs = getPreferences(); + username = prefs.getString(PREF_GPODNET_USERNAME, null); + password = prefs.getString(PREF_GPODNET_PASSWORD, null); + deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null); + lastSyncTimestamp = prefs.getLong(PREF_LAST_SYNC_TIMESTAMP, 0); + addedFeeds = readListFromString(prefs.getString(PREF_SYNC_ADDED, "")); + removedFeeds = readListFromString(prefs.getString(PREF_SYNC_REMOVED, "")); + hostname = checkGpodnetHostname(prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST)); + + preferencesLoaded = true; + } + } + + private static void writePreference(String key, String value) { + SharedPreferences.Editor editor = getPreferences().edit(); + editor.putString(key, value); + editor.commit(); + } + + private static void writePreference(String key, long value) { + SharedPreferences.Editor editor = getPreferences().edit(); + editor.putLong(key, value); + editor.commit(); + } + + private static void writePreference(String key, Collection<String> value) { + SharedPreferences.Editor editor = getPreferences().edit(); + editor.putString(key, writeListToString(value)); + editor.commit(); + } + + public static String getUsername() { + ensurePreferencesLoaded(); + return username; + } + + public static void setUsername(String username) { + GpodnetPreferences.username = username; + writePreference(PREF_GPODNET_USERNAME, username); + } + + public static String getPassword() { + ensurePreferencesLoaded(); + return password; + } + + public static void setPassword(String password) { + GpodnetPreferences.password = password; + writePreference(PREF_GPODNET_PASSWORD, password); + } + + public static String getDeviceID() { + ensurePreferencesLoaded(); + return deviceID; + } + + public static void setDeviceID(String deviceID) { + GpodnetPreferences.deviceID = deviceID; + writePreference(PREF_GPODNET_DEVICEID, deviceID); + } + + public static long getLastSyncTimestamp() { + ensurePreferencesLoaded(); + return lastSyncTimestamp; + } + + public static void setLastSyncTimestamp(long lastSyncTimestamp) { + GpodnetPreferences.lastSyncTimestamp = lastSyncTimestamp; + writePreference(PREF_LAST_SYNC_TIMESTAMP, lastSyncTimestamp); + } + + public static String getHostname() { + ensurePreferencesLoaded(); + return hostname; + } + + public static void setHostname(String value) { + value = checkGpodnetHostname(value); + if (!value.equals(hostname)) { + logout(); + writePreference(PREF_GPODNET_HOSTNAME, value); + hostname = value; + } + } + + public static void addAddedFeed(String feed) { + ensurePreferencesLoaded(); + feedListLock.lock(); + if (addedFeeds.add(feed)) { + writePreference(PREF_SYNC_ADDED, addedFeeds); + } + if (removedFeeds.remove(feed)) { + writePreference(PREF_SYNC_REMOVED, removedFeeds); + } + feedListLock.unlock(); + GpodnetSyncService.sendSyncIntent(PodcastApp.getInstance()); + } + + public static void addRemovedFeed(String feed) { + ensurePreferencesLoaded(); + feedListLock.lock(); + if (removedFeeds.add(feed)) { + writePreference(PREF_SYNC_REMOVED, removedFeeds); + } + if (addedFeeds.remove(feed)) { + writePreference(PREF_SYNC_ADDED, addedFeeds); + } + feedListLock.unlock(); + GpodnetSyncService.sendSyncIntent(PodcastApp.getInstance()); + } + + public static Set<String> getAddedFeedsCopy() { + ensurePreferencesLoaded(); + Set<String> copy = new HashSet<String>(); + feedListLock.lock(); + copy.addAll(addedFeeds); + feedListLock.unlock(); + return copy; + } + + public static void removeAddedFeeds(Collection<String> removed) { + ensurePreferencesLoaded(); + feedListLock.lock(); + addedFeeds.removeAll(removed); + writePreference(PREF_SYNC_ADDED, addedFeeds); + feedListLock.unlock(); + } + + public static Set<String> getRemovedFeedsCopy() { + ensurePreferencesLoaded(); + Set<String> copy = new HashSet<String>(); + feedListLock.lock(); + copy.addAll(removedFeeds); + feedListLock.unlock(); + return copy; + } + + public static void removeRemovedFeeds(Collection<String> removed) { + ensurePreferencesLoaded(); + removedFeeds.removeAll(removed); + writePreference(PREF_SYNC_REMOVED, removedFeeds); + + } + + /** + * Returns true if device ID, username and password have a non-null value + */ + public static boolean loggedIn() { + ensurePreferencesLoaded(); + return deviceID != null && username != null && password != null; + } + + public static synchronized void logout() { + if (BuildConfig.DEBUG) Log.d(TAG, "Logout: Clearing preferences"); + setUsername(null); + setPassword(null); + setDeviceID(null); + addedFeeds.clear(); + writePreference(PREF_SYNC_ADDED, addedFeeds); + removedFeeds.clear(); + writePreference(PREF_SYNC_REMOVED, removedFeeds); + setLastSyncTimestamp(0); + } + + private static Set<String> readListFromString(String s) { + Set<String> result = new HashSet<String>(); + for (String item : s.split(" ")) { + result.add(item); + } + return result; + } + + private static String writeListToString(Collection<String> c) { + StringBuilder result = new StringBuilder(); + for (String item : c) { + result.append(item); + result.append(" "); + } + return result.toString().trim(); + } + + private static String checkGpodnetHostname(String value) { + int startIndex = 0; + if (value.startsWith("http://")) { + startIndex = "http://".length(); + } else if (value.startsWith("https://")) { + startIndex = "https://".length(); + } + return value.substring(startIndex); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java b/app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java new file mode 100644 index 000000000..1d1ab052f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java @@ -0,0 +1,146 @@ +package de.danoeh.antennapod.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; + +/** + * Provides access to preferences set by the playback service. A private + * instance of this class must first be instantiated via createInstance() or + * otherwise every public method will throw an Exception when called. + */ +public class PlaybackPreferences implements + SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = "PlaybackPreferences"; + + /** + * Contains the feed id of the currently playing item if it is a FeedMedia + * object. + */ + public static final String PREF_CURRENTLY_PLAYING_FEED_ID = "de.danoeh.antennapod.preferences.lastPlayedFeedId"; + + /** + * Contains the id of the currently playing FeedMedia object or + * NO_MEDIA_PLAYING if the currently playing media is no FeedMedia object. + */ + public static final String PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID = "de.danoeh.antennapod.preferences.lastPlayedFeedMediaId"; + + /** + * Type of the media object that is currently being played. This preference + * is set to NO_MEDIA_PLAYING after playback has been completed and is set + * as soon as the 'play' button is pressed. + */ + public static final String PREF_CURRENTLY_PLAYING_MEDIA = "de.danoeh.antennapod.preferences.currentlyPlayingMedia"; + + /** True if last played media was streamed. */ + public static final String PREF_CURRENT_EPISODE_IS_STREAM = "de.danoeh.antennapod.preferences.lastIsStream"; + + /** True if last played media was a video. */ + public static final String PREF_CURRENT_EPISODE_IS_VIDEO = "de.danoeh.antennapod.preferences.lastIsVideo"; + + /** Value of PREF_CURRENTLY_PLAYING_MEDIA if no media is playing. */ + public static final long NO_MEDIA_PLAYING = -1; + + private long currentlyPlayingFeedId; + private long currentlyPlayingFeedMediaId; + private long currentlyPlayingMedia; + private boolean currentEpisodeIsStream; + private boolean currentEpisodeIsVideo; + + private static PlaybackPreferences instance; + private Context context; + + private PlaybackPreferences(Context context) { + this.context = context; + loadPreferences(); + } + + /** + * Sets up the UserPreferences class. + * + * @throws IllegalArgumentException + * if context is null + * */ + public static void createInstance(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating new instance of UserPreferences"); + Validate.notNull(context); + + instance = new PlaybackPreferences(context); + + PreferenceManager.getDefaultSharedPreferences(context) + .registerOnSharedPreferenceChangeListener(instance); + } + + private void loadPreferences() { + SharedPreferences sp = PreferenceManager + .getDefaultSharedPreferences(context); + currentlyPlayingFeedId = sp.getLong(PREF_CURRENTLY_PLAYING_FEED_ID, -1); + currentlyPlayingFeedMediaId = sp.getLong( + PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, NO_MEDIA_PLAYING); + currentlyPlayingMedia = sp.getLong(PREF_CURRENTLY_PLAYING_MEDIA, + NO_MEDIA_PLAYING); + currentEpisodeIsStream = sp.getBoolean(PREF_CURRENT_EPISODE_IS_STREAM, true); + currentEpisodeIsVideo = sp.getBoolean(PREF_CURRENT_EPISODE_IS_VIDEO, false); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sp, String key) { + if (key.equals(PREF_CURRENTLY_PLAYING_FEED_ID)) { + currentlyPlayingFeedId = sp.getLong(PREF_CURRENTLY_PLAYING_FEED_ID, + -1); + + } else if (key.equals(PREF_CURRENTLY_PLAYING_MEDIA)) { + currentlyPlayingMedia = sp + .getLong(PREF_CURRENTLY_PLAYING_MEDIA, -1); + + } else if (key.equals(PREF_CURRENT_EPISODE_IS_STREAM)) { + currentEpisodeIsStream = sp.getBoolean(PREF_CURRENT_EPISODE_IS_STREAM, true); + + } else if (key.equals(PREF_CURRENT_EPISODE_IS_VIDEO)) { + currentEpisodeIsVideo = sp.getBoolean(PREF_CURRENT_EPISODE_IS_VIDEO, false); + + } else if (key.equals(PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID)) { + currentlyPlayingFeedMediaId = sp.getLong( + PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, NO_MEDIA_PLAYING); + } + } + + private static void instanceAvailable() { + if (instance == null) { + throw new IllegalStateException( + "UserPreferences was used before being set up"); + } + } + + + public static long getLastPlayedFeedId() { + instanceAvailable(); + return instance.currentlyPlayingFeedId; + } + + public static long getCurrentlyPlayingMedia() { + instanceAvailable(); + return instance.currentlyPlayingMedia; + } + + public static long getCurrentlyPlayingFeedMediaId() { + return instance.currentlyPlayingFeedMediaId; + } + + public static boolean getCurrentEpisodeIsStream() { + instanceAvailable(); + return instance.currentEpisodeIsStream; + } + + public static boolean getCurrentEpisodeIsVideo() { + instanceAvailable(); + return instance.currentEpisodeIsVideo; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java b/app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java new file mode 100644 index 000000000..2020ddfae --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java @@ -0,0 +1,577 @@ +package de.danoeh.antennapod.preferences; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; +import org.json.JSONArray; +import org.json.JSONException; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.OpmlImportFromPathActivity; +import de.danoeh.antennapod.receiver.FeedUpdateReceiver; + +/** + * Provides access to preferences set by the user in the settings screen. A + * private instance of this class must first be instantiated via + * createInstance() or otherwise every public method will throw an Exception + * when called. + */ +public class UserPreferences implements + SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = "UserPreferences"; + + public static final String PREF_PAUSE_ON_HEADSET_DISCONNECT = "prefPauseOnHeadsetDisconnect"; + public static final String PREF_FOLLOW_QUEUE = "prefFollowQueue"; + public static final String PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY = "prefDownloadMediaOnWifiOnly"; + public static final String PREF_UPDATE_INTERVAL = "prefAutoUpdateIntervall"; + public static final String PREF_MOBILE_UPDATE = "prefMobileUpdate"; + public static final String PREF_DISPLAY_ONLY_EPISODES = "prefDisplayOnlyEpisodes"; + public static final String PREF_AUTO_DELETE = "prefAutoDelete"; + public static final String PREF_AUTO_FLATTR = "pref_auto_flattr"; + public static final String PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD = "prefAutoFlattrPlayedDurationThreshold"; + public static final String PREF_THEME = "prefTheme"; + public static final String PREF_DATA_FOLDER = "prefDataFolder"; + public static final String PREF_ENABLE_AUTODL = "prefEnableAutoDl"; + public static final String PREF_ENABLE_AUTODL_WIFI_FILTER = "prefEnableAutoDownloadWifiFilter"; + private static final String PREF_AUTODL_SELECTED_NETWORKS = "prefAutodownloadSelectedNetworks"; + public static final String PREF_EPISODE_CACHE_SIZE = "prefEpisodeCacheSize"; + private static final String PREF_PLAYBACK_SPEED = "prefPlaybackSpeed"; + private static final String PREF_PLAYBACK_SPEED_ARRAY = "prefPlaybackSpeedArray"; + public static final String PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS = "prefPauseForFocusLoss"; + private static final String PREF_SEEK_DELTA_SECS = "prefSeekDeltaSecs"; + + // TODO: Make this value configurable + private static final float PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT = 0.8f; + + private static int EPISODE_CACHE_SIZE_UNLIMITED = -1; + + private static UserPreferences instance; + private final Context context; + + // Preferences + private boolean pauseOnHeadsetDisconnect; + private boolean followQueue; + private boolean downloadMediaOnWifiOnly; + private long updateInterval; + private boolean allowMobileUpdate; + private boolean displayOnlyEpisodes; + private boolean autoDelete; + private boolean autoFlattr; + private float autoFlattrPlayedDurationThreshold; + private int theme; + private boolean enableAutodownload; + private boolean enableAutodownloadWifiFilter; + private String[] autodownloadSelectedNetworks; + private int episodeCacheSize; + private String playbackSpeed; + private String[] playbackSpeedArray; + private boolean pauseForFocusLoss; + private int seekDeltaSecs; + private boolean isFreshInstall; + + private UserPreferences(Context context) { + this.context = context; + loadPreferences(); + } + + /** + * Sets up the UserPreferences class. + * + * @throws IllegalArgumentException if context is null + */ + public static void createInstance(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating new instance of UserPreferences"); + Validate.notNull(context); + + instance = new UserPreferences(context); + + createImportDirectory(); + createNoMediaFile(); + PreferenceManager.getDefaultSharedPreferences(context) + .registerOnSharedPreferenceChangeListener(instance); + + } + + private void loadPreferences() { + SharedPreferences sp = PreferenceManager + .getDefaultSharedPreferences(context); + EPISODE_CACHE_SIZE_UNLIMITED = context.getResources().getInteger( + R.integer.episode_cache_size_unlimited); + pauseOnHeadsetDisconnect = sp.getBoolean( + PREF_PAUSE_ON_HEADSET_DISCONNECT, true); + followQueue = sp.getBoolean(PREF_FOLLOW_QUEUE, false); + downloadMediaOnWifiOnly = sp.getBoolean( + PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY, true); + updateInterval = readUpdateInterval(sp.getString(PREF_UPDATE_INTERVAL, + "0")); + allowMobileUpdate = sp.getBoolean(PREF_MOBILE_UPDATE, false); + displayOnlyEpisodes = sp.getBoolean(PREF_DISPLAY_ONLY_EPISODES, false); + autoDelete = sp.getBoolean(PREF_AUTO_DELETE, false); + autoFlattr = sp.getBoolean(PREF_AUTO_FLATTR, false); + autoFlattrPlayedDurationThreshold = sp.getFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD, + PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT); + theme = readThemeValue(sp.getString(PREF_THEME, "0")); + enableAutodownloadWifiFilter = sp.getBoolean( + PREF_ENABLE_AUTODL_WIFI_FILTER, false); + autodownloadSelectedNetworks = StringUtils.split( + sp.getString(PREF_AUTODL_SELECTED_NETWORKS, ""), ','); + episodeCacheSize = readEpisodeCacheSizeInternal(sp.getString( + PREF_EPISODE_CACHE_SIZE, "20")); + enableAutodownload = sp.getBoolean(PREF_ENABLE_AUTODL, false); + playbackSpeed = sp.getString(PREF_PLAYBACK_SPEED, "1.0"); + playbackSpeedArray = readPlaybackSpeedArray(sp.getString( + PREF_PLAYBACK_SPEED_ARRAY, null)); + pauseForFocusLoss = sp.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, false); + seekDeltaSecs = Integer.valueOf(sp.getString(PREF_SEEK_DELTA_SECS, "30")); + } + + private int readThemeValue(String valueFromPrefs) { + switch (Integer.parseInt(valueFromPrefs)) { + case 0: + return R.style.Theme_AntennaPod_Light; + case 1: + return R.style.Theme_AntennaPod_Dark; + default: + return R.style.Theme_AntennaPod_Light; + } + } + + private long readUpdateInterval(String valueFromPrefs) { + int hours = Integer.parseInt(valueFromPrefs); + return TimeUnit.HOURS.toMillis(hours); + } + + private int readEpisodeCacheSizeInternal(String valueFromPrefs) { + if (valueFromPrefs.equals(context + .getString(R.string.pref_episode_cache_unlimited))) { + return EPISODE_CACHE_SIZE_UNLIMITED; + } else { + return Integer.valueOf(valueFromPrefs); + } + } + + private String[] readPlaybackSpeedArray(String valueFromPrefs) { + String[] selectedSpeeds = null; + // If this preference hasn't been set yet, return the default options + if (valueFromPrefs == null) { + String[] allSpeeds = context.getResources().getStringArray( + R.array.playback_speed_values); + List<String> speedList = new LinkedList<String>(); + for (String speedStr : allSpeeds) { + float speed = Float.parseFloat(speedStr); + if (speed < 2.0001 && speed * 10 % 1 == 0) { + speedList.add(speedStr); + } + } + selectedSpeeds = speedList.toArray(new String[speedList.size()]); + } else { + try { + JSONArray jsonArray = new JSONArray(valueFromPrefs); + selectedSpeeds = new String[jsonArray.length()]; + for (int i = 0; i < jsonArray.length(); i++) { + selectedSpeeds[i] = jsonArray.getString(i); + } + } catch (JSONException e) { + Log.e(TAG, + "Got JSON error when trying to get speeds from JSONArray"); + e.printStackTrace(); + } + } + return selectedSpeeds; + } + + private static void instanceAvailable() { + if (instance == null) { + throw new IllegalStateException( + "UserPreferences was used before being set up"); + } + } + + public static boolean isPauseOnHeadsetDisconnect() { + instanceAvailable(); + return instance.pauseOnHeadsetDisconnect; + } + + public static boolean isFollowQueue() { + instanceAvailable(); + return instance.followQueue; + } + + public static boolean isDownloadMediaOnWifiOnly() { + instanceAvailable(); + return instance.downloadMediaOnWifiOnly; + } + + public static long getUpdateInterval() { + instanceAvailable(); + return instance.updateInterval; + } + + public static boolean isAllowMobileUpdate() { + instanceAvailable(); + return instance.allowMobileUpdate; + } + + public static boolean isDisplayOnlyEpisodes() { + instanceAvailable(); + //return instance.displayOnlyEpisodes; + return false; + } + + public static boolean isAutoDelete() { + instanceAvailable(); + return instance.autoDelete; + } + + public static boolean isAutoFlattr() { + instanceAvailable(); + return instance.autoFlattr; + } + + /** + * Returns the time after which an episode should be auto-flattr'd in percent of the episode's + * duration. + */ + public static float getAutoFlattrPlayedDurationThreshold() { + instanceAvailable(); + return instance.autoFlattrPlayedDurationThreshold; + } + + public static int getTheme() { + instanceAvailable(); + return instance.theme; + } + + public static boolean isEnableAutodownloadWifiFilter() { + instanceAvailable(); + return instance.enableAutodownloadWifiFilter; + } + + public static String[] getAutodownloadSelectedNetworks() { + instanceAvailable(); + return instance.autodownloadSelectedNetworks; + } + + public static int getEpisodeCacheSizeUnlimited() { + return EPISODE_CACHE_SIZE_UNLIMITED; + } + + public static String getPlaybackSpeed() { + instanceAvailable(); + return instance.playbackSpeed; + } + + public static String[] getPlaybackSpeedArray() { + instanceAvailable(); + return instance.playbackSpeedArray; + } + + public static int getSeekDeltaMs() { + instanceAvailable(); + return 1000 * instance.seekDeltaSecs; + } + + /** + * Returns the capacity of the episode cache. This method will return the + * negative integer EPISODE_CACHE_SIZE_UNLIMITED if the cache size is set to + * 'unlimited'. + */ + public static int getEpisodeCacheSize() { + instanceAvailable(); + return instance.episodeCacheSize; + } + + public static boolean isEnableAutodownload() { + instanceAvailable(); + return instance.enableAutodownload; + } + + public static boolean shouldPauseForFocusLoss() { + instanceAvailable(); + return instance.pauseForFocusLoss; + } + + public static boolean isFreshInstall() { + instanceAvailable(); + return instance.isFreshInstall; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sp, String key) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Registered change of user preferences. Key: " + key); + + if (key.equals(PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY)) { + downloadMediaOnWifiOnly = sp.getBoolean( + PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY, true); + + } else if (key.equals(PREF_MOBILE_UPDATE)) { + allowMobileUpdate = sp.getBoolean(PREF_MOBILE_UPDATE, false); + + } else if (key.equals(PREF_FOLLOW_QUEUE)) { + followQueue = sp.getBoolean(PREF_FOLLOW_QUEUE, false); + + } else if (key.equals(PREF_UPDATE_INTERVAL)) { + updateInterval = readUpdateInterval(sp.getString( + PREF_UPDATE_INTERVAL, "0")); + restartUpdateAlarm(updateInterval); + + } else if (key.equals(PREF_AUTO_DELETE)) { + autoDelete = sp.getBoolean(PREF_AUTO_DELETE, false); + + } else if (key.equals(PREF_AUTO_FLATTR)) { + autoFlattr = sp.getBoolean(PREF_AUTO_FLATTR, false); + } else if (key.equals(PREF_DISPLAY_ONLY_EPISODES)) { + displayOnlyEpisodes = sp.getBoolean(PREF_DISPLAY_ONLY_EPISODES, + false); + } else if (key.equals(PREF_THEME)) { + theme = readThemeValue(sp.getString(PREF_THEME, "")); + } else if (key.equals(PREF_ENABLE_AUTODL_WIFI_FILTER)) { + enableAutodownloadWifiFilter = sp.getBoolean( + PREF_ENABLE_AUTODL_WIFI_FILTER, false); + } else if (key.equals(PREF_AUTODL_SELECTED_NETWORKS)) { + autodownloadSelectedNetworks = StringUtils.split( + sp.getString(PREF_AUTODL_SELECTED_NETWORKS, ""), ','); + } else if (key.equals(PREF_EPISODE_CACHE_SIZE)) { + episodeCacheSize = readEpisodeCacheSizeInternal(sp.getString( + PREF_EPISODE_CACHE_SIZE, "20")); + } else if (key.equals(PREF_ENABLE_AUTODL)) { + enableAutodownload = sp.getBoolean(PREF_ENABLE_AUTODL, false); + } else if (key.equals(PREF_PLAYBACK_SPEED)) { + playbackSpeed = sp.getString(PREF_PLAYBACK_SPEED, "1.0"); + } else if (key.equals(PREF_PLAYBACK_SPEED_ARRAY)) { + playbackSpeedArray = readPlaybackSpeedArray(sp.getString( + PREF_PLAYBACK_SPEED_ARRAY, null)); + } else if (key.equals(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS)) { + pauseForFocusLoss = sp.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, false); + } else if (key.equals(PREF_SEEK_DELTA_SECS)) { + seekDeltaSecs = Integer.valueOf(sp.getString(PREF_SEEK_DELTA_SECS, "30")); + } else if (key.equals(PREF_PAUSE_ON_HEADSET_DISCONNECT)) { + pauseOnHeadsetDisconnect = sp.getBoolean(PREF_PAUSE_ON_HEADSET_DISCONNECT, true); + } else if (key.equals(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD)) { + autoFlattrPlayedDurationThreshold = sp.getFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD, + PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT); + } + } + + public static void setPlaybackSpeed(String speed) { + PreferenceManager.getDefaultSharedPreferences(instance.context).edit() + .putString(PREF_PLAYBACK_SPEED, speed).apply(); + } + + public static void setPlaybackSpeedArray(String[] speeds) { + JSONArray jsonArray = new JSONArray(); + for (String speed : speeds) { + jsonArray.put(speed); + } + PreferenceManager.getDefaultSharedPreferences(instance.context).edit() + .putString(PREF_PLAYBACK_SPEED_ARRAY, jsonArray.toString()) + .apply(); + } + + public static void setAutodownloadSelectedNetworks(Context context, + String[] value) { + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(context.getApplicationContext()) + .edit(); + editor.putString(PREF_AUTODL_SELECTED_NETWORKS, + StringUtils.join(value, ',')); + editor.commit(); + } + + /** + * Sets the update interval value. Should only be used for testing purposes! + */ + public static void setUpdateInterval(Context context, long newValue) { + instanceAvailable(); + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(context.getApplicationContext()) + .edit(); + editor.putString(PREF_UPDATE_INTERVAL, + String.valueOf(newValue)); + editor.commit(); + instance.updateInterval = newValue; + } + + /** + * Change the auto-flattr settings + * + * @param context For accessing the shared preferences + * @param enabled Whether automatic flattring should be enabled at all + * @param autoFlattrThreshold The percentage of playback time after which an episode should be + * flattrd. Must be a value between 0 and 1 (inclusive) + * */ + public static void setAutoFlattrSettings(Context context, boolean enabled, float autoFlattrThreshold) { + instanceAvailable(); + Validate.inclusiveBetween(0.0, 1.0, autoFlattrThreshold); + PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()) + .edit() + .putBoolean(PREF_AUTO_FLATTR, enabled) + .putFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD, autoFlattrThreshold) + .commit(); + instance.autoFlattr = enabled; + instance.autoFlattrPlayedDurationThreshold = autoFlattrThreshold; + } + + /** + * Return the folder where the app stores all of its data. This method will + * return the standard data folder if none has been set by the user. + * + * @param type The name of the folder inside the data folder. May be null + * when accessing the root of the data folder. + * @return The data folder that has been requested or null if the folder + * could not be created. + */ + public static File getDataFolder(Context context, String type) { + instanceAvailable(); + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(context.getApplicationContext()); + String strDir = prefs.getString(PREF_DATA_FOLDER, null); + if (strDir == null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Using default data folder"); + return context.getExternalFilesDir(type); + } else { + File dataDir = new File(strDir); + if (!dataDir.exists()) { + if (!dataDir.mkdir()) { + Log.w(TAG, "Could not create data folder"); + return null; + } + } + + if (type == null) { + return dataDir; + } else { + // handle path separators + String[] dirs = type.split("/"); + for (int i = 0; i < dirs.length; i++) { + if (dirs.length > 0) { + if (i < dirs.length - 1) { + dataDir = getDataFolder(context, dirs[i]); + if (dataDir == null) { + return null; + } + } + type = dirs[i]; + } + } + File typeDir = new File(dataDir, type); + if (!typeDir.exists()) { + if (dataDir.canWrite()) { + if (!typeDir.mkdir()) { + Log.e(TAG, "Could not create data folder named " + + type); + return null; + } + } + } + return typeDir; + } + + } + } + + public static void setDataFolder(String dir) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Result from DirectoryChooser: " + dir); + instanceAvailable(); + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(instance.context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(PREF_DATA_FOLDER, dir); + editor.commit(); + createImportDirectory(); + } + + /** + * Create a .nomedia file to prevent scanning by the media scanner. + */ + private static void createNoMediaFile() { + File f = new File(instance.context.getExternalFilesDir(null), + ".nomedia"); + if (!f.exists()) { + try { + f.createNewFile(); + } catch (IOException e) { + Log.e(TAG, "Could not create .nomedia file"); + e.printStackTrace(); + } + if (BuildConfig.DEBUG) + Log.d(TAG, ".nomedia file created"); + } + } + + /** + * Creates the import directory if it doesn't exist and if storage is + * available + */ + private static void createImportDirectory() { + File importDir = getDataFolder(instance.context, + OpmlImportFromPathActivity.IMPORT_DIR); + if (importDir != null) { + if (importDir.exists()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Import directory already exists"); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating import directory"); + importDir.mkdir(); + } + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Could not access external storage."); + } + } + + /** + * Updates alarm registered with the AlarmManager service or deactivates it. + * + * @param millis new value to register with AlarmManager. If millis is 0, the + * alarm is deactivated. + */ + public static void restartUpdateAlarm(long millis) { + instanceAvailable(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Restarting update alarm. New value: " + millis); + AlarmManager alarmManager = (AlarmManager) instance.context + .getSystemService(Context.ALARM_SERVICE); + PendingIntent updateIntent = PendingIntent.getBroadcast( + instance.context, 0, new Intent( + FeedUpdateReceiver.ACTION_REFRESH_FEEDS), 0 + ); + alarmManager.cancel(updateIntent); + if (millis != 0) { + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, millis, millis, + updateIntent); + if (BuildConfig.DEBUG) + Log.d(TAG, "Changed alarm to new interval"); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Automatic update was deactivated"); + } + } + + /** + * Reads episode cache size as it is saved in the episode_cache_size_values array. + */ + public static int readEpisodeCacheSize(String valueFromPrefs) { + instanceAvailable(); + return instance.readEpisodeCacheSizeInternal(valueFromPrefs); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java new file mode 100644 index 000000000..a0539e276 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java @@ -0,0 +1,33 @@ +package de.danoeh.antennapod.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.preferences.UserPreferences; + +/** Listens for events that make it necessary to reset the update alarm. */ +public class AlarmUpdateReceiver extends BroadcastReceiver { + private static final String TAG = "AlarmUpdateReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received intent"); + if (StringUtils.equals(intent.getAction(), Intent.ACTION_BOOT_COMPLETED)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Resetting update alarm after reboot"); + } else if (StringUtils.equals(intent.getAction(), Intent.ACTION_PACKAGE_REPLACED)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Resetting update alarm after app upgrade"); + } + + UserPreferences.restartUpdateAlarm(UserPreferences.getUpdateInterval()); + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java new file mode 100644 index 000000000..4dcf0b6aa --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.NetworkUtils; + +public class ConnectivityActionReceiver extends BroadcastReceiver { + private static final String TAG = "ConnectivityActionReceiver"; + + @Override + public void onReceive(final Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), ConnectivityManager.CONNECTIVITY_ACTION)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received intent"); + + if (NetworkUtils.autodownloadNetworkAvailable(context)) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "auto-dl network available, starting auto-download"); + DBTasks.autodownloadUndownloadedItems(context); + } else { // if new network is Wi-Fi, finish ongoing downloads, + // otherwise cancel all downloads + ConnectivityManager cm = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo ni = cm.getActiveNetworkInfo(); + if (ni == null || ni.getType() != ConnectivityManager.TYPE_WIFI) { + if (BuildConfig.DEBUG) + Log.i(TAG, + "Device is no longer connected to Wi-Fi. Cancelling ongoing downloads"); + DownloadRequester.getInstance().cancelAllDownloads(context); + } + + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java new file mode 100644 index 000000000..3c283a30b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.storage.DBTasks; + +/** Refreshes all feeds when it receives an intent */ +public class FeedUpdateReceiver extends BroadcastReceiver { + private static final String TAG = "FeedUpdateReceiver"; + public static final String ACTION_REFRESH_FEEDS = "de.danoeh.antennapod.feedupdatereceiver.refreshFeeds"; + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), ACTION_REFRESH_FEEDS)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received intent"); + boolean mobileUpdate = UserPreferences.isAllowMobileUpdate(); + if (mobileUpdate || connectedToWifi(context)) { + DBTasks.refreshExpiredFeeds(context); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Blocking automatic update: no wifi available / no mobile updates allowed"); + } + } + } + + private boolean connectedToWifi(Context context) { + ConnectivityManager connManager = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo mWifi = connManager + .getNetworkInfo(ConnectivityManager.TYPE_WIFI); + + return mWifi.isConnected(); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java new file mode 100644 index 000000000..1edebd275 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java @@ -0,0 +1,32 @@ +package de.danoeh.antennapod.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.view.KeyEvent; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.service.playback.PlaybackService; + +/** Receives media button events. */ +public class MediaButtonReceiver extends BroadcastReceiver { + private static final String TAG = "MediaButtonReceiver"; + public static final String EXTRA_KEYCODE = "de.danoeh.antennapod.service.extra.MediaButtonReceiver.KEYCODE"; + + public static final String NOTIFY_BUTTON_RECEIVER = "de.danoeh.antennapod.NOTIFY_BUTTON_RECEIVER"; + + @Override + public void onReceive(Context context, Intent intent) { + if (BuildConfig.DEBUG) Log.d(TAG, "Received intent"); + KeyEvent event = (KeyEvent) intent.getExtras().get( + Intent.EXTRA_KEY_EVENT); + if (event.getAction() == KeyEvent.ACTION_DOWN) { + Intent serviceIntent = new Intent(context, PlaybackService.class); + int keycode = event.getKeyCode(); + serviceIntent.putExtra(EXTRA_KEYCODE, keycode); + context.startService(serviceIntent); + } + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java b/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java new file mode 100644 index 000000000..9f8892181 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java @@ -0,0 +1,50 @@ +package de.danoeh.antennapod.receiver; + +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.service.playback.PlayerWidgetService; + +public class PlayerWidget extends AppWidgetProvider { + private static final String TAG = "PlayerWidget"; + public static final String FORCE_WIDGET_UPDATE = "de.danoeh.antennapod.FORCE_WIDGET_UPDATE"; + public static final String STOP_WIDGET_UPDATE = "de.danoeh.antennapod.STOP_WIDGET_UPDATE"; + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), FORCE_WIDGET_UPDATE)) { + startUpdate(context); + } else if (StringUtils.equals(intent.getAction(), STOP_WIDGET_UPDATE)) { + stopUpdate(context); + } + + } + + @Override + public void onEnabled(Context context) { + super.onEnabled(context); + if (BuildConfig.DEBUG) + Log.d(TAG, "Widget enabled"); + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, + int[] appWidgetIds) { + startUpdate(context); + } + + private void startUpdate(Context context) { + context.startService(new Intent(context, PlayerWidgetService.class)); + } + + private void stopUpdate(Context context) { + context.stopService(new Intent(context, PlayerWidgetService.class)); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java new file mode 100644 index 000000000..b0430d170 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java @@ -0,0 +1,55 @@ +package de.danoeh.antennapod.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.widget.Toast; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.Date; + +/** + * Receives intents from AntennaPod Single Purpose apps + */ +public class SPAReceiver extends BroadcastReceiver{ + private static final String TAG = "SPAReceiver"; + + public static final String ACTION_SP_APPS_QUERY_FEEDS = "de.danoeh.antennapdsp.intent.SP_APPS_QUERY_FEEDS"; + public static final String ACTION_SP_APPS_QUERY_FEEDS_REPSONSE = "de.danoeh.antennapdsp.intent.SP_APPS_QUERY_FEEDS_RESPONSE"; + public static final String ACTION_SP_APPS_QUERY_FEEDS_REPSONSE_FEEDS_EXTRA = "feeds"; + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), ACTION_SP_APPS_QUERY_FEEDS_REPSONSE)) { + if (BuildConfig.DEBUG) Log.d(TAG, "Received SP_APPS_QUERY_RESPONSE"); + if (intent.hasExtra(ACTION_SP_APPS_QUERY_FEEDS_REPSONSE_FEEDS_EXTRA)) { + String[] feedUrls = intent.getStringArrayExtra(ACTION_SP_APPS_QUERY_FEEDS_REPSONSE_FEEDS_EXTRA); + if (feedUrls != null) { + if (BuildConfig.DEBUG) Log.d(TAG, "Received feeds list: " + Arrays.toString(feedUrls)); + for (String url : feedUrls) { + Feed f = new Feed(url, new Date()); + try { + DownloadRequester.getInstance().downloadFeed(context, f); + } catch (DownloadRequestException e) { + Log.e(TAG, "Error while trying to add feed " + url); + e.printStackTrace(); + } + } + Toast.makeText(context, R.string.sp_apps_importing_feeds_msg, Toast.LENGTH_LONG).show(); + + } else { + Log.e(TAG, "Received invalid SP_APPS_QUERY_REPSONSE: extra was null"); + } + } else { + Log.e(TAG, "Received invalid SP_APPS_QUERY_RESPONSE: Contains no extra"); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java b/app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java new file mode 100644 index 000000000..c8c9fc31e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java @@ -0,0 +1,245 @@ +package de.danoeh.antennapod.service; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceAuthenticationException; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetSubscriptionChange; +import de.danoeh.antennapod.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.preferences.GpodnetPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.NetworkUtils; + +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * Synchronizes local subscriptions with gpodder.net service. The service should be started with ACTION_SYNC as an action argument. + * This class also provides static methods for starting the GpodnetSyncService. + */ +public class GpodnetSyncService extends Service { + private static final String TAG = "GpodnetSyncService"; + + private static final long WAIT_INTERVAL = 5000L; + + public static final String ARG_ACTION = "action"; + + public static final String ACTION_SYNC = "de.danoeh.antennapod.intent.action.sync"; + + private GpodnetService service; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + final String action = (intent != null) ? intent.getStringExtra(ARG_ACTION) : null; + if (action != null && action.equals(ACTION_SYNC)) { + Log.d(TAG, String.format("Waiting %d milliseconds before uploading changes", WAIT_INTERVAL)); + syncWaiterThread.restart(); + } else { + Log.e(TAG, "Received invalid intent: action argument is null or invalid"); + } + return START_FLAG_REDELIVERY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (BuildConfig.DEBUG) Log.d(TAG, "onDestroy"); + syncWaiterThread.interrupt(); + + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private synchronized GpodnetService tryLogin() throws GpodnetServiceException { + if (service == null) { + service = new GpodnetService(); + service.authenticate(GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); + } + return service; + } + + private synchronized void syncChanges() { + if (GpodnetPreferences.loggedIn() && NetworkUtils.networkAvailable(this)) { + final long timestamp = GpodnetPreferences.getLastSyncTimestamp(); + try { + final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls(this); + GpodnetService service = tryLogin(); + + if (timestamp == 0) { + // first sync: download all subscriptions... + GpodnetSubscriptionChange changes = + service.getSubscriptionChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), 0); + if (BuildConfig.DEBUG) Log.d(TAG, "Downloaded subscription changes: " + changes); + processSubscriptionChanges(localSubscriptions, changes); + + // ... then upload all local subscriptions + if (BuildConfig.DEBUG) Log.d(TAG, "Uploading subscription list: " + localSubscriptions); + GpodnetUploadChangesResponse uploadChangesResponse = + service.uploadChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), localSubscriptions, new LinkedList<String>()); + if (BuildConfig.DEBUG) Log.d(TAG, "Uploading changes response: " + uploadChangesResponse); + GpodnetPreferences.removeAddedFeeds(localSubscriptions); + GpodnetPreferences.removeRemovedFeeds(GpodnetPreferences.getRemovedFeedsCopy()); + GpodnetPreferences.setLastSyncTimestamp(uploadChangesResponse.timestamp); + } else { + Set<String> added = GpodnetPreferences.getAddedFeedsCopy(); + Set<String> removed = GpodnetPreferences.getRemovedFeedsCopy(); + + // download remote changes first... + GpodnetSubscriptionChange subscriptionChanges = service.getSubscriptionChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), timestamp); + if (BuildConfig.DEBUG) Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges); + processSubscriptionChanges(localSubscriptions, subscriptionChanges); + + // ... then upload changes local changes + if (BuildConfig.DEBUG) Log.d(TAG, String.format("Uploading subscriptions, Added: %s\nRemoved: %s", + added.toString(), removed)); + GpodnetUploadChangesResponse uploadChangesResponse = service.uploadChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), added, removed); + if (BuildConfig.DEBUG) Log.d(TAG, "Upload subscriptions response: " + uploadChangesResponse); + + GpodnetPreferences.removeAddedFeeds(added); + GpodnetPreferences.removeRemovedFeeds(removed); + GpodnetPreferences.setLastSyncTimestamp(uploadChangesResponse.timestamp); + } + clearErrorNotifications(); + } catch (GpodnetServiceException e) { + e.printStackTrace(); + updateErrorNotification(e); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + } + stopSelf(); + } + + private synchronized void processSubscriptionChanges(List<String> localSubscriptions, GpodnetSubscriptionChange changes) throws DownloadRequestException { + for (String downloadUrl : changes.getAdded()) { + if (!localSubscriptions.contains(downloadUrl)) { + Feed feed = new Feed(downloadUrl, new Date()); + DownloadRequester.getInstance().downloadFeed(this, feed); + } + } + for (String downloadUrl : changes.getRemoved()) { + DBTasks.removeFeedWithDownloadUrl(GpodnetSyncService.this, downloadUrl); + } + } + + private void clearErrorNotifications() { + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + nm.cancel(R.id.notification_gpodnet_sync_error); + nm.cancel(R.id.notification_gpodnet_sync_autherror); + } + + private void updateErrorNotification(GpodnetServiceException exception) { + if (BuildConfig.DEBUG) Log.d(TAG, "Posting error notification"); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + final String title; + final String description; + final int id; + if (exception instanceof GpodnetServiceAuthenticationException) { + title = getString(R.string.gpodnetsync_auth_error_title); + description = getString(R.string.gpodnetsync_auth_error_descr); + id = R.id.notification_gpodnet_sync_autherror; + } else { + title = getString(R.string.gpodnetsync_error_title); + description = getString(R.string.gpodnetsync_error_descr) + exception.getMessage(); + id = R.id.notification_gpodnet_sync_error; + } + + PendingIntent activityIntent = PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); + + Notification notification = builder.setContentTitle(title) + .setContentText(description) + .setContentIntent(activityIntent) + .setSmallIcon(R.drawable.stat_notify_sync_error) + .setAutoCancel(true) + .build(); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(id, notification); + } + + private WaiterThread syncWaiterThread = new WaiterThread(WAIT_INTERVAL) { + @Override + public void onWaitCompleted() { + syncChanges(); + } + }; + + private abstract class WaiterThread { + private long waitInterval; + private Thread thread; + + private WaiterThread(long waitInterval) { + this.waitInterval = waitInterval; + reinit(); + } + + public abstract void onWaitCompleted(); + + public void exec() { + if (!thread.isAlive()) { + thread.start(); + } + } + + private void reinit() { + if (thread != null && thread.isAlive()) { + Log.d(TAG, "Interrupting waiter thread"); + thread.interrupt(); + } + thread = new Thread() { + @Override + public void run() { + try { + Thread.sleep(waitInterval); + } catch (InterruptedException e) { + e.printStackTrace(); + } + if (!isInterrupted()) { + synchronized (this) { + onWaitCompleted(); + } + } + } + }; + } + + public void restart() { + reinit(); + exec(); + } + + public void interrupt() { + if (thread != null && thread.isAlive()) { + thread.interrupt(); + } + } + } + + public static void sendSyncIntent(Context context) { + if (GpodnetPreferences.loggedIn()) { + Intent intent = new Intent(context, GpodnetSyncService.class); + intent.putExtra(ARG_ACTION, ACTION_SYNC); + context.startService(intent); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java b/app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java new file mode 100644 index 000000000..ddf8d605d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java @@ -0,0 +1,54 @@ +package de.danoeh.antennapod.service.download; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import org.apache.http.Header; +import org.apache.http.HttpResponse; +import org.apache.http.impl.client.DefaultRedirectHandler; +import org.apache.http.protocol.HttpContext; + +import java.net.URI; + +public class APRedirectHandler extends DefaultRedirectHandler { + // Identifier for logger + private static final String TAG = "APRedirectHandler"; + // Header field, which has to be potentially fixed + private static final String LOC = "Location"; + // Regular expressions for character strings, which should not appear in URLs + private static final String CHi[] = { "\\{", "\\}", "\\|", "\\\\", "\\^", "~", "\\[", "\\]", "\\`"}; + private static final String CHo[] = { "%7B", "%7D", "%7C", "%5C", "%5E", "%7E", "%5B", "%5D", "%60"}; + + /** + * Workaround for broken URLs in redirection. + * Proper solution involves LaxRedirectStrategy() which is not available in + * current API yet. + */ + @Override + public URI getLocationURI(HttpResponse response, HttpContext context) + throws org.apache.http.ProtocolException { + + Header h[] = response.getHeaders(LOC); + if (h.length>0) { + String s = h[0].getValue(); + + // Fix broken URL + for(int i=0; i<CHi.length;i++) + s = s.replaceAll(CHi[i], CHo[i]); + + // If anything had to be fixed, then replace the header + if (!s.equals(h[0].getValue())) + { + if (BuildConfig.DEBUG) + Log.d(TAG, "Original URL: " + h[0].getValue()); + + response.setHeader(LOC, s); + + if (BuildConfig.DEBUG) + Log.d(TAG, "Fixed URL: " + s); + } + } + + // call DefaultRedirectHandler with fixed URL + return super.getLocationURI(response, context); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java b/app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java new file mode 100644 index 000000000..be331ce9b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java @@ -0,0 +1,96 @@ +package de.danoeh.antennapod.service.download; + +import android.util.Log; +import de.danoeh.antennapod.AppConfig; +import de.danoeh.antennapod.BuildConfig; +import org.apache.http.client.HttpClient; +import org.apache.http.client.params.HttpClientParams; +import org.apache.http.conn.ClientConnectionManager; +import org.apache.http.conn.params.ConnManagerPNames; +import org.apache.http.conn.scheme.PlainSocketFactory; +import org.apache.http.conn.scheme.Scheme; +import org.apache.http.conn.scheme.SchemeRegistry; +import org.apache.http.conn.ssl.SSLSocketFactory; +import org.apache.http.impl.client.AbstractHttpClient; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.CoreProtocolPNames; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; + +import java.util.concurrent.TimeUnit; + +/** + * Provides access to a HttpClient singleton. + */ +public class AntennapodHttpClient { + private static final String TAG = "AntennapodHttpClient"; + + public static final long EXPIRED_CONN_TIMEOUT_SEC = 30; + + public static final int MAX_REDIRECTS = 5; + public static final int CONNECTION_TIMEOUT = 30000; + public static final int SOCKET_TIMEOUT = 30000; + + public static final int MAX_CONNECTIONS = 8; + + + private static volatile HttpClient httpClient = null; + + /** + * Returns the HttpClient singleton. + */ + public static synchronized HttpClient getHttpClient() { + if (httpClient == null) { + if (BuildConfig.DEBUG) Log.d(TAG, "Creating new instance of HTTP client"); + + HttpParams params = new BasicHttpParams(); + params.setParameter(CoreProtocolPNames.USER_AGENT, AppConfig.USER_AGENT); + params.setIntParameter("http.protocol.max-redirects", MAX_REDIRECTS); + params.setBooleanParameter("http.protocol.reject-relative-redirect", + false); + HttpConnectionParams.setSoTimeout(params, SOCKET_TIMEOUT); + HttpConnectionParams.setConnectionTimeout(params, CONNECTION_TIMEOUT); + HttpClientParams.setRedirecting(params, true); + + httpClient = new DefaultHttpClient(createClientConnectionManager(), params); + // Workaround for broken URLs in redirection + ((AbstractHttpClient) httpClient) + .setRedirectHandler(new APRedirectHandler()); + } + return httpClient; + } + + /** + * Closes expired connections. This method should be called by the using class once has finished its work with + * the HTTP client. + */ + public static synchronized void cleanup() { + if (httpClient != null) { + httpClient.getConnectionManager().closeExpiredConnections(); + httpClient.getConnectionManager().closeIdleConnections(EXPIRED_CONN_TIMEOUT_SEC, TimeUnit.SECONDS); + } + } + + + private static ClientConnectionManager createClientConnectionManager() { + HttpParams params = new BasicHttpParams(); + params.setIntParameter(ConnManagerPNames.MAX_TOTAL_CONNECTIONS, MAX_CONNECTIONS); + return new ThreadSafeClientConnManager(params, prepareSchemeRegistry()); + } + + private static SchemeRegistry prepareSchemeRegistry() { + SchemeRegistry sr = new SchemeRegistry(); + + Scheme http = new Scheme("http", + PlainSocketFactory.getSocketFactory(), 80); + sr.register(http); + Scheme https = new Scheme("https", + SSLSocketFactory.getSocketFactory(), 443); + sr.register(https); + + return sr; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java new file mode 100644 index 000000000..e803d30d4 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java @@ -0,0 +1,209 @@ +package de.danoeh.antennapod.service.download; + +import android.os.Parcel; +import android.os.Parcelable; + +import org.apache.commons.lang3.Validate; + +public class DownloadRequest implements Parcelable { + + private final String destination; + private final String source; + private final String title; + private String username; + private String password; + private boolean deleteOnFailure; + private final long feedfileId; + private final int feedfileType; + + protected int progressPercent; + protected long soFar; + protected long size; + protected int statusMsg; + + public DownloadRequest(String destination, String source, String title, + long feedfileId, int feedfileType, String username, String password, boolean deleteOnFailure) { + Validate.notNull(destination); + Validate.notNull(source); + Validate.notNull(title); + + this.destination = destination; + this.source = source; + this.title = title; + this.feedfileId = feedfileId; + this.feedfileType = feedfileType; + this.username = username; + this.password = password; + this.deleteOnFailure = deleteOnFailure; + } + + public DownloadRequest(String destination, String source, String title, + long feedfileId, int feedfileType) { + this(destination, source, title, feedfileId, feedfileType, null, null, true); + } + + private DownloadRequest(Parcel in) { + destination = in.readString(); + source = in.readString(); + title = in.readString(); + feedfileId = in.readLong(); + feedfileType = in.readInt(); + deleteOnFailure = (in.readByte() > 0); + if (in.dataAvail() > 0) { + username = in.readString(); + } else { + username = null; + } + if (in.dataAvail() > 0) { + password = in.readString(); + } else { + password = null; + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(destination); + dest.writeString(source); + dest.writeString(title); + dest.writeLong(feedfileId); + dest.writeInt(feedfileType); + dest.writeByte((deleteOnFailure) ? (byte) 1 : 0); + if (username != null) { + dest.writeString(username); + } + if (password != null) { + dest.writeString(password); + } + } + + public static final Parcelable.Creator<DownloadRequest> CREATOR = new Parcelable.Creator<DownloadRequest>() { + public DownloadRequest createFromParcel(Parcel in) { + return new DownloadRequest(in); + } + + public DownloadRequest[] newArray(int size) { + return new DownloadRequest[size]; + } + }; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DownloadRequest that = (DownloadRequest) o; + + if (deleteOnFailure != that.deleteOnFailure) return false; + if (feedfileId != that.feedfileId) return false; + if (feedfileType != that.feedfileType) return false; + if (progressPercent != that.progressPercent) return false; + if (size != that.size) return false; + if (soFar != that.soFar) return false; + if (statusMsg != that.statusMsg) return false; + if (destination != null ? !destination.equals(that.destination) : that.destination != null) + return false; + if (password != null ? !password.equals(that.password) : that.password != null) + return false; + if (source != null ? !source.equals(that.source) : that.source != null) return false; + if (title != null ? !title.equals(that.title) : that.title != null) return false; + if (username != null ? !username.equals(that.username) : that.username != null) + return false; + + return true; + } + + @Override + public int hashCode() { + int result = destination != null ? destination.hashCode() : 0; + result = 31 * result + (source != null ? source.hashCode() : 0); + result = 31 * result + (title != null ? title.hashCode() : 0); + result = 31 * result + (username != null ? username.hashCode() : 0); + result = 31 * result + (password != null ? password.hashCode() : 0); + result = 31 * result + (deleteOnFailure ? 1 : 0); + result = 31 * result + (int) (feedfileId ^ (feedfileId >>> 32)); + result = 31 * result + feedfileType; + result = 31 * result + progressPercent; + result = 31 * result + (int) (soFar ^ (soFar >>> 32)); + result = 31 * result + (int) (size ^ (size >>> 32)); + result = 31 * result + statusMsg; + return result; + } + + public String getDestination() { + return destination; + } + + public String getSource() { + return source; + } + + public String getTitle() { + return title; + } + + public long getFeedfileId() { + return feedfileId; + } + + public int getFeedfileType() { + return feedfileType; + } + + public int getProgressPercent() { + return progressPercent; + } + + public void setProgressPercent(int progressPercent) { + this.progressPercent = progressPercent; + } + + public long getSoFar() { + return soFar; + } + + public void setSoFar(long soFar) { + this.soFar = soFar; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public int getStatusMsg() { + return statusMsg; + } + + public void setStatusMsg(int statusMsg) { + this.statusMsg = statusMsg; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isDeleteOnFailure() { + return deleteOnFailure; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java new file mode 100644 index 000000000..63be91b57 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java @@ -0,0 +1,1230 @@ +package de.danoeh.antennapod.service.download; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadataRetriever; +import android.os.Binder; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.webkit.URLUtil; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; +import org.apache.http.HttpStatus; +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionService; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.xml.parsers.ParserConfigurationException; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.DownloadAuthenticationActivity; +import de.danoeh.antennapod.activity.MainActivity; +import de.danoeh.antennapod.adapter.NavListAdapter; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.feed.FeedPreferences; +import de.danoeh.antennapod.fragment.DownloadsFragment; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.syndication.handler.FeedHandler; +import de.danoeh.antennapod.syndication.handler.UnsupportedFeedtypeException; +import de.danoeh.antennapod.util.ChapterUtils; +import de.danoeh.antennapod.util.DownloadError; +import de.danoeh.antennapod.util.InvalidFeedException; + +/** + * Manages the download of feedfiles in the app. Downloads can be enqueued viathe startService intent. + * The argument of the intent is an instance of DownloadRequest in the EXTRA_REQUEST field of + * the intent. + * After the downloads have finished, the downloaded object will be passed on to a specific handler, depending on the + * type of the feedfile. + */ +public class DownloadService extends Service { + private static final String TAG = "DownloadService"; + + /** + * Cancels one download. The intent MUST have an EXTRA_DOWNLOAD_URL extra that contains the download URL of the + * object whose download should be cancelled. + */ + public static final String ACTION_CANCEL_DOWNLOAD = "action.de.danoeh.antennapod.service.cancelDownload"; + + /** + * Cancels all running downloads. + */ + public static final String ACTION_CANCEL_ALL_DOWNLOADS = "action.de.danoeh.antennapod.service.cancelAllDownloads"; + + /** + * Extra for ACTION_CANCEL_DOWNLOAD + */ + public static final String EXTRA_DOWNLOAD_URL = "downloadUrl"; + + /** + * Sent by the DownloadService when the content of the downloads list + * changes. + */ + public static final String ACTION_DOWNLOADS_CONTENT_CHANGED = "action.de.danoeh.antennapod.service.downloadsContentChanged"; + + /** + * Extra for ACTION_ENQUEUE_DOWNLOAD intent. + */ + public static final String EXTRA_REQUEST = "request"; + + /** + * Stores new media files that will be queued for auto-download if possible. + */ + private List<Long> newMediaFiles; + + /** + * Contains all completed downloads that have not been included in the report yet. + */ + private List<DownloadStatus> reportQueue; + + private ExecutorService syncExecutor; + private CompletionService<Downloader> downloadExecutor; + private FeedSyncThread feedSyncThread; + + /** + * Number of threads of downloadExecutor. + */ + private static final int NUM_PARALLEL_DOWNLOADS = 6; + + private DownloadRequester requester; + + + private NotificationCompat.Builder notificationCompatBuilder; + private Notification.BigTextStyle notificationBuilder; + private int NOTIFICATION_ID = 2; + private int REPORT_ID = 3; + + /** + * Currently running downloads. + */ + private List<Downloader> downloads; + + /** + * Number of running downloads. + */ + private AtomicInteger numberOfDownloads; + + /** + * True if service is running. + */ + public static boolean isRunning = false; + + private Handler handler; + + private NotificationUpdater notificationUpdater; + private ScheduledFuture notificationUpdaterFuture; + private static final int SCHED_EX_POOL_SIZE = 1; + private ScheduledThreadPoolExecutor schedExecutor; + + private final IBinder mBinder = new LocalBinder(); + + public class LocalBinder extends Binder { + public DownloadService getService() { + return DownloadService.this; + } + } + + private Thread downloadCompletionThread = new Thread() { + private static final String TAG = "downloadCompletionThread"; + + @Override + public void run() { + if (BuildConfig.DEBUG) Log.d(TAG, "downloadCompletionThread was started"); + while (!isInterrupted()) { + try { + Downloader downloader = downloadExecutor.take().get(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Received 'Download Complete' - message."); + removeDownload(downloader); + DownloadStatus status = downloader.getResult(); + boolean successful = status.isSuccessful(); + + final int type = status.getFeedfileType(); + if (successful) { + if (type == Feed.FEEDFILETYPE_FEED) { + handleCompletedFeedDownload(downloader + .getDownloadRequest()); + } else if (type == FeedImage.FEEDFILETYPE_FEEDIMAGE) { + handleCompletedImageDownload(status, downloader.getDownloadRequest()); + } else if (type == FeedMedia.FEEDFILETYPE_FEEDMEDIA) { + handleCompletedFeedMediaDownload(status, downloader.getDownloadRequest()); + } + } else { + numberOfDownloads.decrementAndGet(); + if (!status.isCancelled()) { + if (status.getReason() == DownloadError.ERROR_UNAUTHORIZED) { + postAuthenticationNotification(downloader.getDownloadRequest()); + } else if (status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR + && Integer.valueOf(status.getReasonDetailed()) == HttpStatus.SC_REQUESTED_RANGE_NOT_SATISFIABLE) { + + Log.d(TAG, "Requested invalid range, restarting download from the beginning"); + FileUtils.deleteQuietly(new File(downloader.getDownloadRequest().getDestination())); + DownloadRequester.getInstance().download(DownloadService.this, downloader.getDownloadRequest()); + } else { + Log.e(TAG, "Download failed"); + saveDownloadStatus(status); + handleFailedDownload(status, downloader.getDownloadRequest()); + } + } + sendDownloadHandledIntent(); + queryDownloadsAsync(); + } + } catch (InterruptedException e) { + if (BuildConfig.DEBUG) Log.d(TAG, "DownloadCompletionThread was interrupted"); + } catch (ExecutionException e) { + e.printStackTrace(); + numberOfDownloads.decrementAndGet(); + } + } + if (BuildConfig.DEBUG) Log.d(TAG, "End of downloadCompletionThread"); + } + }; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent.getParcelableExtra(EXTRA_REQUEST) != null) { + onDownloadQueued(intent); + } else if (numberOfDownloads.get() == 0) { + stopSelf(); + } + return Service.START_NOT_STICKY; + } + + @SuppressLint("NewApi") + @Override + public void onCreate() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Service started"); + isRunning = true; + handler = new Handler(); + newMediaFiles = Collections.synchronizedList(new ArrayList<Long>()); + reportQueue = Collections.synchronizedList(new ArrayList<DownloadStatus>()); + downloads = new ArrayList<Downloader>(); + numberOfDownloads = new AtomicInteger(0); + + IntentFilter cancelDownloadReceiverFilter = new IntentFilter(); + cancelDownloadReceiverFilter.addAction(ACTION_CANCEL_ALL_DOWNLOADS); + cancelDownloadReceiverFilter.addAction(ACTION_CANCEL_DOWNLOAD); + registerReceiver(cancelDownloadReceiver, cancelDownloadReceiverFilter); + syncExecutor = Executors.newSingleThreadExecutor(new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + }); + downloadExecutor = new ExecutorCompletionService<Downloader>( + Executors.newFixedThreadPool(NUM_PARALLEL_DOWNLOADS, + new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + } + ) + ); + schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE, + new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + }, new RejectedExecutionHandler() { + + @Override + public void rejectedExecution(Runnable r, + ThreadPoolExecutor executor) { + Log.w(TAG, "SchedEx rejected submission of new task"); + } + } + ); + downloadCompletionThread.start(); + feedSyncThread = new FeedSyncThread(); + feedSyncThread.start(); + + setupNotificationBuilders(); + requester = DownloadRequester.getInstance(); + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onDestroy() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Service shutting down"); + isRunning = false; + updateReport(); + + stopForeground(true); + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + nm.cancel(NOTIFICATION_ID); + + downloadCompletionThread.interrupt(); + syncExecutor.shutdown(); + schedExecutor.shutdown(); + feedSyncThread.shutdown(); + cancelNotificationUpdater(); + unregisterReceiver(cancelDownloadReceiver); + + if (!newMediaFiles.isEmpty()) { + DBTasks.autodownloadUndownloadedItems(getApplicationContext(), + ArrayUtils.toPrimitive(newMediaFiles.toArray(new Long[newMediaFiles.size()]))); + } + } + + @SuppressLint("NewApi") + private void setupNotificationBuilders() { + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_NAV_TYPE, NavListAdapter.VIEW_TYPE_NAV); + intent.putExtra(MainActivity.EXTRA_NAV_INDEX, MainActivity.POS_DOWNLOADS); + Bundle args = new Bundle(); + args.putInt(DownloadsFragment.ARG_SELECTED_TAB, DownloadsFragment.POS_RUNNING); + intent.putExtra(MainActivity.EXTRA_FRAGMENT_ARGS, args); + + PendingIntent pIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + + + Bitmap icon = BitmapFactory.decodeResource(getResources(), + R.drawable.stat_notify_sync); + + if (android.os.Build.VERSION.SDK_INT >= 16) { + notificationBuilder = new Notification.BigTextStyle( + new Notification.Builder(this).setOngoing(true) + .setContentIntent(pIntent).setLargeIcon(icon) + .setSmallIcon(R.drawable.stat_notify_sync) + ); + } else { + notificationCompatBuilder = new NotificationCompat.Builder(this) + .setOngoing(true).setContentIntent(pIntent) + .setLargeIcon(icon) + .setSmallIcon(R.drawable.stat_notify_sync); + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Notification set up"); + } + + /** + * Updates the contents of the service's notifications. Should be called + * before setupNotificationBuilders. + */ + @SuppressLint("NewApi") + private Notification updateNotifications() { + String contentTitle = getString(R.string.download_notification_title); + int numDownloads = requester.getNumberOfDownloads(); + String downloadsLeft; + if (numDownloads > 0) { + downloadsLeft = requester.getNumberOfDownloads() + + getString(R.string.downloads_left); + } else { + downloadsLeft = getString(R.string.downloads_processing); + } + if (android.os.Build.VERSION.SDK_INT >= 16) { + + if (notificationBuilder != null) { + + StringBuilder bigText = new StringBuilder(""); + for (int i = 0; i < downloads.size(); i++) { + Downloader downloader = downloads.get(i); + final DownloadRequest request = downloader + .getDownloadRequest(); + if (request.getFeedfileType() == Feed.FEEDFILETYPE_FEED) { + if (request.getTitle() != null) { + if (i > 0) { + bigText.append("\n"); + } + bigText.append("\u2022 " + request.getTitle()); + } + } else if (request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) { + if (request.getTitle() != null) { + if (i > 0) { + bigText.append("\n"); + } + bigText.append("\u2022 " + request.getTitle() + + " (" + request.getProgressPercent() + + "%)"); + } + } + + } + notificationBuilder.setSummaryText(downloadsLeft); + notificationBuilder.setBigContentTitle(contentTitle); + if (bigText != null) { + notificationBuilder.bigText(bigText.toString()); + } + return notificationBuilder.build(); + } + } else { + if (notificationCompatBuilder != null) { + notificationCompatBuilder.setContentTitle(contentTitle); + notificationCompatBuilder.setContentText(downloadsLeft); + return notificationCompatBuilder.build(); + } + } + return null; + } + + private Downloader getDownloader(String downloadUrl) { + for (Downloader downloader : downloads) { + if (downloader.getDownloadRequest().getSource().equals(downloadUrl)) { + return downloader; + } + } + return null; + } + + private BroadcastReceiver cancelDownloadReceiver = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), ACTION_CANCEL_DOWNLOAD)) { + String url = intent.getStringExtra(EXTRA_DOWNLOAD_URL); + Validate.notNull(url, "ACTION_CANCEL_DOWNLOAD intent needs download url extra"); + + if (BuildConfig.DEBUG) + Log.d(TAG, "Cancelling download with url " + url); + Downloader d = getDownloader(url); + if (d != null) { + d.cancel(); + } else { + Log.e(TAG, "Could not cancel download with url " + url); + } + + } else if (StringUtils.equals(intent.getAction(), ACTION_CANCEL_ALL_DOWNLOADS)) { + for (Downloader d : downloads) { + d.cancel(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Cancelled all downloads"); + } + sendBroadcast(new Intent(ACTION_DOWNLOADS_CONTENT_CHANGED)); + + } + queryDownloads(); + } + + }; + + private void onDownloadQueued(Intent intent) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received enqueue request"); + DownloadRequest request = intent.getParcelableExtra(EXTRA_REQUEST); + if (request == null) { + throw new IllegalArgumentException( + "ACTION_ENQUEUE_DOWNLOAD intent needs request extra"); + } + + Downloader downloader = getDownloader(request); + if (downloader != null) { + numberOfDownloads.incrementAndGet(); + downloads.add(downloader); + downloadExecutor.submit(downloader); + sendBroadcast(new Intent(ACTION_DOWNLOADS_CONTENT_CHANGED)); + } + + queryDownloads(); + } + + private Downloader getDownloader(DownloadRequest request) { + if (URLUtil.isHttpUrl(request.getSource()) + || URLUtil.isHttpsUrl(request.getSource())) { + return new HttpDownloader(request); + } + Log.e(TAG, + "Could not find appropriate downloader for " + + request.getSource() + ); + return null; + } + + /** + * Remove download from the DownloadRequester list and from the + * DownloadService list. + */ + private void removeDownload(final Downloader d) { + handler.post(new Runnable() { + @Override + public void run() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Removing downloader: " + + d.getDownloadRequest().getSource()); + boolean rc = downloads.remove(d); + if (BuildConfig.DEBUG) + Log.d(TAG, "Result of downloads.remove: " + rc); + DownloadRequester.getInstance().removeDownload(d.getDownloadRequest()); + sendBroadcast(new Intent(ACTION_DOWNLOADS_CONTENT_CHANGED)); + } + }); + } + + /** + * Adds a new DownloadStatus object to the list of completed downloads and + * saves it in the database + * + * @param status the download that is going to be saved + */ + private void saveDownloadStatus(DownloadStatus status) { + reportQueue.add(status); + DBWriter.addDownloadStatus(this, status); + } + + private void sendDownloadHandledIntent() { + EventDistributor.getInstance().sendDownloadHandledBroadcast(); + } + + /** + * Creates a notification at the end of the service lifecycle to notify the + * user about the number of completed downloads. A report will only be + * created if the number of successfully downloaded feeds is bigger than 1 + * or if there is at least one failed download which is not an image or if + * there is at least one downloaded media file. + */ + private void updateReport() { + // check if report should be created + boolean createReport = false; + int successfulDownloads = 0; + int failedDownloads = 0; + + // a download report is created if at least one download has failed + // (excluding failed image downloads) + for (DownloadStatus status : reportQueue) { + if (status.isSuccessful()) { + successfulDownloads++; + } else if (!status.isCancelled()) { + if (status.getFeedfileType() != FeedImage.FEEDFILETYPE_FEEDIMAGE) { + createReport = true; + } + failedDownloads++; + } + } + + if (createReport) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Creating report"); + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_NAV_TYPE, NavListAdapter.VIEW_TYPE_NAV); + intent.putExtra(MainActivity.EXTRA_NAV_INDEX, MainActivity.POS_DOWNLOADS); + Bundle args = new Bundle(); + args.putInt(DownloadsFragment.ARG_SELECTED_TAB, DownloadsFragment.POS_LOG); + intent.putExtra(MainActivity.EXTRA_FRAGMENT_ARGS, args); + + // create notification object + Notification notification = new NotificationCompat.Builder(this) + .setTicker( + getString(de.danoeh.antennapod.R.string.download_report_title)) + .setContentTitle( + getString(de.danoeh.antennapod.R.string.download_report_title)) + .setContentText( + String.format( + getString(R.string.download_report_content), + successfulDownloads, failedDownloads) + ) + .setSmallIcon(R.drawable.stat_notify_sync) + .setLargeIcon( + BitmapFactory.decodeResource(getResources(), + R.drawable.stat_notify_sync) + ) + .setContentIntent( + PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + ) + .setAutoCancel(true).build(); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(REPORT_ID, notification); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "No report is created"); + } + reportQueue.clear(); + } + + /** + * Calls query downloads on the services main thread. This method should be used instead of queryDownloads if it is + * used from a thread other than the main thread. + */ + void queryDownloadsAsync() { + handler.post(new Runnable() { + public void run() { + queryDownloads(); + ; + } + }); + } + + /** + * Check if there's something else to download, otherwise stop + */ + void queryDownloads() { + if (BuildConfig.DEBUG) { + Log.d(TAG, numberOfDownloads.get() + " downloads left"); + } + + if (numberOfDownloads.get() <= 0 && DownloadRequester.getInstance().hasNoDownloads()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Number of downloads is " + numberOfDownloads.get() + ", attempting shutdown"); + stopSelf(); + } else { + setupNotificationUpdater(); + startForeground(NOTIFICATION_ID, updateNotifications()); + } + } + + private void postAuthenticationNotification(final DownloadRequest downloadRequest) { + handler.post(new Runnable() { + @Override + public void run() { + final String resourceTitle = (downloadRequest.getTitle() != null) + ? downloadRequest.getTitle() : downloadRequest.getSource(); + + final Intent activityIntent = new Intent(getApplicationContext(), DownloadAuthenticationActivity.class); + activityIntent.putExtra(DownloadAuthenticationActivity.ARG_DOWNLOAD_REQUEST, downloadRequest); + activityIntent.putExtra(DownloadAuthenticationActivity.ARG_SEND_TO_DOWNLOAD_REQUESTER_BOOL, true); + final PendingIntent contentIntent = PendingIntent.getActivity(getApplicationContext(), 0, activityIntent, PendingIntent.FLAG_ONE_SHOT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(DownloadService.this); + builder.setTicker(getText(R.string.authentication_notification_title)) + .setContentTitle(getText(R.string.authentication_notification_title)) + .setContentText(getText(R.string.authentication_notification_msg)) + .setStyle(new NotificationCompat.BigTextStyle().bigText(getText(R.string.authentication_notification_msg) + + ": " + resourceTitle)) + .setSmallIcon(R.drawable.ic_stat_authentication) + .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_stat_authentication)) + .setAutoCancel(true) + .setContentIntent(contentIntent); + Notification n = builder.build(); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(downloadRequest.getSource().hashCode(), n); + } + }); + } + + /** + * Is called whenever a Feed is downloaded + */ + private void handleCompletedFeedDownload(DownloadRequest request) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Handling completed Feed Download"); + feedSyncThread.submitCompletedDownload(request); + + } + + /** + * Is called whenever a Feed-Image is downloaded + */ + private void handleCompletedImageDownload(DownloadStatus status, DownloadRequest request) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Handling completed Image Download"); + syncExecutor.execute(new ImageHandlerThread(status, request)); + } + + /** + * Is called whenever a FeedMedia is downloaded. + */ + private void handleCompletedFeedMediaDownload(DownloadStatus status, DownloadRequest request) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Handling completed FeedMedia Download"); + syncExecutor.execute(new MediaHandlerThread(status, request)); + } + + private void handleFailedDownload(DownloadStatus status, DownloadRequest request) { + if (BuildConfig.DEBUG) Log.d(TAG, "Handling failed download"); + syncExecutor.execute(new FailedDownloadHandler(status, request)); + } + + /** + * Takes a single Feed, parses the corresponding file and refreshes + * information in the manager + */ + class FeedSyncThread extends Thread { + private static final String TAG = "FeedSyncThread"; + + private BlockingQueue<DownloadRequest> completedRequests = new LinkedBlockingDeque<DownloadRequest>(); + private CompletionService<Feed> parserService = new ExecutorCompletionService<Feed>(Executors.newSingleThreadExecutor()); + private ExecutorService dbService = Executors.newSingleThreadExecutor(); + private Future<?> dbUpdateFuture; + private volatile boolean isActive = true; + private volatile boolean isCollectingRequests = false; + + private final long WAIT_TIMEOUT = 3000; + + + /** + * Waits for completed requests. Once the first request has been taken, the method will wait WAIT_TIMEOUT ms longer to + * collect more completed requests. + * + * @return Collected feeds or null if the method has been interrupted during the first waiting period. + */ + private List<Feed> collectCompletedRequests() { + List<Feed> results = new LinkedList<Feed>(); + DownloadRequester requester = DownloadRequester.getInstance(); + int tasks = 0; + + try { + DownloadRequest request = completedRequests.take(); + parserService.submit(new FeedParserTask(request)); + tasks++; + } catch (InterruptedException e) { + return null; + } + + tasks += pollCompletedDownloads(); + + isCollectingRequests = true; + + if (requester.isDownloadingFeeds()) { + // wait for completion of more downloads + long startTime = System.currentTimeMillis(); + long currentTime = startTime; + while (requester.isDownloadingFeeds() && (currentTime - startTime) < WAIT_TIMEOUT) { + try { + if (BuildConfig.DEBUG) + Log.d(TAG, "Waiting for " + (startTime + WAIT_TIMEOUT - currentTime) + " ms"); + sleep(startTime + WAIT_TIMEOUT - currentTime); + } catch (InterruptedException e) { + if (BuildConfig.DEBUG) + Log.d(TAG, "interrupted while waiting for more downloads"); + tasks += pollCompletedDownloads(); + } finally { + currentTime = System.currentTimeMillis(); + } + } + + tasks += pollCompletedDownloads(); + + } + + isCollectingRequests = false; + + for (int i = 0; i < tasks; i++) { + try { + Feed f = parserService.take().get(); + if (f != null) { + results.add(f); + } + } catch (InterruptedException e) { + e.printStackTrace(); + + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + return results; + } + + private int pollCompletedDownloads() { + int tasks = 0; + for (int i = 0; i < completedRequests.size(); i++) { + parserService.submit(new FeedParserTask(completedRequests.poll())); + tasks++; + } + return tasks; + } + + @Override + public void run() { + while (isActive) { + final List<Feed> feeds = collectCompletedRequests(); + + if (feeds == null) { + continue; + } + + if (BuildConfig.DEBUG) Log.d(TAG, "Bundling " + feeds.size() + " feeds"); + + for (Feed feed : feeds) { + removeDuplicateImages(feed); // duplicate images have to removed because the DownloadRequester does not accept two downloads with the same download URL yet. + } + + // Save information of feed in DB + if (dbUpdateFuture != null) { + try { + dbUpdateFuture.get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + dbUpdateFuture = dbService.submit(new Runnable() { + @Override + public void run() { + Feed[] savedFeeds = DBTasks.updateFeed(DownloadService.this, feeds.toArray(new Feed[feeds.size()])); + + for (Feed savedFeed : savedFeeds) { + // Download Feed Image if provided and not downloaded + if (savedFeed.getImage() != null + && savedFeed.getImage().isDownloaded() == false) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Feed has image; Downloading...."); + savedFeed.getImage().setOwner(savedFeed); + final Feed savedFeedRef = savedFeed; + try { + requester.downloadImage(DownloadService.this, + savedFeedRef.getImage()); + } catch (DownloadRequestException e) { + e.printStackTrace(); + DBWriter.addDownloadStatus( + DownloadService.this, + new DownloadStatus( + savedFeedRef.getImage(), + savedFeedRef + .getImage() + .getHumanReadableIdentifier(), + DownloadError.ERROR_REQUEST_ERROR, + false, e.getMessage() + ) + ); + } + } + + // queue new media files for automatic download + for (FeedItem item : savedFeed.getItems()) { + if (!item.isRead() && item.hasMedia() && !item.getMedia().isDownloaded()) { + newMediaFiles.add(item.getMedia().getId()); + } + } + + numberOfDownloads.decrementAndGet(); + } + + sendDownloadHandledIntent(); + + queryDownloadsAsync(); + } + }); + + } + + if (dbUpdateFuture != null) { + try { + dbUpdateFuture.get(); + } catch (InterruptedException e) { + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + if (BuildConfig.DEBUG) Log.d(TAG, "Shutting down"); + + } + + private class FeedParserTask implements Callable<Feed> { + + private DownloadRequest request; + + private FeedParserTask(DownloadRequest request) { + this.request = request; + } + + @Override + public Feed call() throws Exception { + return parseFeed(request); + } + } + + private Feed parseFeed(DownloadRequest request) { + Feed savedFeed = null; + + Feed feed = new Feed(request.getSource(), new Date()); + feed.setFile_url(request.getDestination()); + feed.setId(request.getFeedfileId()); + feed.setDownloaded(true); + feed.setPreferences(new FeedPreferences(0, true, request.getUsername(), request.getPassword())); + + DownloadError reason = null; + String reasonDetailed = null; + boolean successful = true; + FeedHandler feedHandler = new FeedHandler(); + + try { + feed = feedHandler.parseFeed(feed).feed; + if (BuildConfig.DEBUG) + Log.d(TAG, feed.getTitle() + " parsed"); + if (checkFeedData(feed) == false) { + throw new InvalidFeedException(); + } + + } catch (SAXException e) { + successful = false; + e.printStackTrace(); + reason = DownloadError.ERROR_PARSER_EXCEPTION; + reasonDetailed = e.getMessage(); + } catch (IOException e) { + successful = false; + e.printStackTrace(); + reason = DownloadError.ERROR_PARSER_EXCEPTION; + reasonDetailed = e.getMessage(); + } catch (ParserConfigurationException e) { + successful = false; + e.printStackTrace(); + reason = DownloadError.ERROR_PARSER_EXCEPTION; + reasonDetailed = e.getMessage(); + } catch (UnsupportedFeedtypeException e) { + e.printStackTrace(); + successful = false; + reason = DownloadError.ERROR_UNSUPPORTED_TYPE; + reasonDetailed = e.getMessage(); + } catch (InvalidFeedException e) { + e.printStackTrace(); + successful = false; + reason = DownloadError.ERROR_PARSER_EXCEPTION; + reasonDetailed = e.getMessage(); + } + + // cleanup(); + if (savedFeed == null) { + savedFeed = feed; + } + + + if (successful) { + return savedFeed; + } else { + numberOfDownloads.decrementAndGet(); + saveDownloadStatus(new DownloadStatus(savedFeed, + savedFeed.getHumanReadableIdentifier(), reason, successful, + reasonDetailed)); + return null; + } + } + + + /** + * Checks if the feed was parsed correctly. + */ + private boolean checkFeedData(Feed feed) { + if (feed.getTitle() == null) { + Log.e(TAG, "Feed has no title."); + return false; + } + if (!hasValidFeedItems(feed)) { + Log.e(TAG, "Feed has invalid items"); + return false; + } + return true; + } + + /** + * Checks if the FeedItems of this feed have images that point + * to the same URL. If two FeedItems have an image that points to + * the same URL, the reference of the second item is removed, so that every image + * reference is unique. + */ + private void removeDuplicateImages(Feed feed) { + for (int x = 0; x < feed.getItems().size(); x++) { + for (int y = x + 1; y < feed.getItems().size(); y++) { + FeedItem item1 = feed.getItems().get(x); + FeedItem item2 = feed.getItems().get(y); + if (item1.hasItemImage() && item2.hasItemImage()) { + if (StringUtils.equals(item1.getImage().getDownload_url(), item2.getImage().getDownload_url())) { + item2.setImage(null); + } + } + } + } + } + + private boolean hasValidFeedItems(Feed feed) { + for (FeedItem item : feed.getItems()) { + if (item.getTitle() == null) { + Log.e(TAG, "Item has no title"); + return false; + } + if (item.getPubDate() == null) { + Log.e(TAG, + "Item has no pubDate. Using current time as pubDate"); + if (item.getTitle() != null) { + Log.e(TAG, "Title of invalid item: " + item.getTitle()); + } + item.setPubDate(new Date()); + } + } + return true; + } + + /** + * Delete files that aren't needed anymore + */ + private void cleanup(Feed feed) { + if (feed.getFile_url() != null) { + if (new File(feed.getFile_url()).delete()) + if (BuildConfig.DEBUG) + Log.d(TAG, "Successfully deleted cache file."); + else + Log.e(TAG, "Failed to delete cache file."); + feed.setFile_url(null); + } else if (BuildConfig.DEBUG) { + Log.d(TAG, "Didn't delete cache file: File url is not set."); + } + } + + public void shutdown() { + isActive = false; + if (isCollectingRequests) { + interrupt(); + } + } + + public void submitCompletedDownload(DownloadRequest request) { + completedRequests.offer(request); + if (isCollectingRequests) { + interrupt(); + } + } + + } + + /** + * Handles failed downloads. + * <p/> + * If the file has been partially downloaded, this handler will set the file_url of the FeedFile to the location + * of the downloaded file. + * <p/> + * Currently, this handler only handles FeedMedia objects, because Feeds and FeedImages are deleted if the download fails. + */ + class FailedDownloadHandler implements Runnable { + + private DownloadRequest request; + private DownloadStatus status; + + FailedDownloadHandler(DownloadStatus status, DownloadRequest request) { + this.request = request; + this.status = status; + } + + @Override + public void run() { + if (request.isDeleteOnFailure()) { + if (BuildConfig.DEBUG) Log.d(TAG, "Ignoring failed download, deleteOnFailure=true"); + } else { + File dest = new File(request.getDestination()); + if (dest.exists() && request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) { + Log.d(TAG, "File has been partially downloaded. Writing file url"); + FeedMedia media = DBReader.getFeedMedia(DownloadService.this, request.getFeedfileId()); + media.setFile_url(request.getDestination()); + try { + DBWriter.setFeedMedia(DownloadService.this, media).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + } + } + } + + /** + * Handles a completed image download. + */ + class ImageHandlerThread implements Runnable { + + private DownloadRequest request; + private DownloadStatus status; + + public ImageHandlerThread(DownloadStatus status, DownloadRequest request) { + Validate.notNull(status); + Validate.notNull(request); + + this.status = status; + this.request = request; + } + + @Override + public void run() { + FeedImage image = DBReader.getFeedImage(DownloadService.this, request.getFeedfileId()); + if (image == null) { + throw new IllegalStateException("Could not find downloaded image in database"); + } + + image.setFile_url(request.getDestination()); + image.setDownloaded(true); + + saveDownloadStatus(status); + sendDownloadHandledIntent(); + DBWriter.setFeedImage(DownloadService.this, image); + numberOfDownloads.decrementAndGet(); + queryDownloadsAsync(); + } + } + + /** + * Handles a completed media download. + */ + class MediaHandlerThread implements Runnable { + + private DownloadRequest request; + private DownloadStatus status; + + public MediaHandlerThread(DownloadStatus status, DownloadRequest request) { + Validate.notNull(status); + Validate.notNull(request); + + this.status = status; + this.request = request; + } + + @Override + public void run() { + FeedMedia media = DBReader.getFeedMedia(DownloadService.this, + request.getFeedfileId()); + if (media == null) { + throw new IllegalStateException( + "Could not find downloaded media object in database"); + } + boolean chaptersRead = false; + media.setDownloaded(true); + media.setFile_url(request.getDestination()); + + // Get duration + MediaMetadataRetriever mmr = null; + try { + mmr = new MediaMetadataRetriever(); + mmr.setDataSource(media.getFile_url()); + String durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + media.setDuration(Integer.parseInt(durationStr)); + if (BuildConfig.DEBUG) + Log.d(TAG, "Duration of file is " + media.getDuration()); + } catch (NumberFormatException e) { + e.printStackTrace(); + } catch (RuntimeException e) { + e.printStackTrace(); + } finally { + if (mmr != null) { + mmr.release(); + } + } + + if (media.getItem().getChapters() == null) { + ChapterUtils.loadChaptersFromFileUrl(media); + if (media.getItem().getChapters() != null) { + chaptersRead = true; + } + } + + try { + if (chaptersRead) { + DBWriter.setFeedItem(DownloadService.this, media.getItem()).get(); + } + DBWriter.setFeedMedia(DownloadService.this, media).get(); + if (!DBTasks.isInQueue(DownloadService.this, media.getItem().getId())) { + DBWriter.addQueueItem(DownloadService.this, media.getItem().getId()).get(); + } + } catch (ExecutionException e) { + e.printStackTrace(); + status = new DownloadStatus(media, media.getEpisodeTitle(), DownloadError.ERROR_DB_ACCESS_ERROR, false, e.getMessage()); + } catch (InterruptedException e) { + e.printStackTrace(); + status = new DownloadStatus(media, media.getEpisodeTitle(), DownloadError.ERROR_DB_ACCESS_ERROR, false, e.getMessage()); + } + + saveDownloadStatus(status); + sendDownloadHandledIntent(); + + numberOfDownloads.decrementAndGet(); + queryDownloadsAsync(); + } + } + + /** + * Schedules the notification updater task if it hasn't been scheduled yet. + */ + private void setupNotificationUpdater() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting up notification updater"); + if (notificationUpdater == null) { + notificationUpdater = new NotificationUpdater(); + notificationUpdaterFuture = schedExecutor.scheduleAtFixedRate( + notificationUpdater, 5L, 5L, TimeUnit.SECONDS); + } + } + + private void cancelNotificationUpdater() { + boolean result = false; + if (notificationUpdaterFuture != null) { + result = notificationUpdaterFuture.cancel(true); + } + notificationUpdater = null; + notificationUpdaterFuture = null; + Log.d(TAG, "NotificationUpdater cancelled. Result: " + result); + } + + private class NotificationUpdater implements Runnable { + public void run() { + handler.post(new Runnable() { + @Override + public void run() { + Notification n = updateNotifications(); + if (n != null) { + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(NOTIFICATION_ID, n); + } + } + }); + } + } + + public List<Downloader> getDownloads() { + return downloads; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java new file mode 100644 index 000000000..1d76770bb --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java @@ -0,0 +1,181 @@ +package de.danoeh.antennapod.service.download; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.feed.FeedFile; +import de.danoeh.antennapod.util.DownloadError; + +import java.util.Date; + +/** Contains status attributes for one download */ +public class DownloadStatus { + /** + * Downloaders should use this constant for the size attribute if necessary + * so that the listadapters etc. can react properly. + */ + public static final int SIZE_UNKNOWN = -1; + + // ----------------------------------- ATTRIBUTES STORED IN DB + /** Unique id for storing the object in database. */ + protected long id; + /** + * A human-readable string which is shown to the user so that he can + * identify the download. Should be the title of the item/feed/media or the + * URL if the download has no other title. + */ + protected String title; + protected DownloadError reason; + /** + * A message which can be presented to the user to give more information. + * Should be null if Download was successful. + */ + protected String reasonDetailed; + protected boolean successful; + protected Date completionDate; + protected long feedfileId; + /** + * Is used to determine the type of the feedfile even if the feedfile does + * not exist anymore. The value should be FEEDFILETYPE_FEED, + * FEEDFILETYPE_FEEDIMAGE or FEEDFILETYPE_FEEDMEDIA + */ + protected int feedfileType; + + // ------------------------------------ NOT STORED IN DB + protected boolean done; + protected boolean cancelled; + + /** Constructor for restoring Download status entries from DB. */ + public DownloadStatus(long id, String title, long feedfileId, + int feedfileType, boolean successful, DownloadError reason, + Date completionDate, String reasonDetailed) { + this.id = id; + this.title = title; + this.done = true; + this.feedfileId = feedfileId; + this.reason = reason; + this.successful = successful; + this.completionDate = (Date) completionDate.clone(); + this.reasonDetailed = reasonDetailed; + this.feedfileType = feedfileType; + } + + public DownloadStatus(DownloadRequest request, DownloadError reason, + boolean successful, boolean cancelled, String reasonDetailed) { + Validate.notNull(request); + + this.title = request.getTitle(); + this.feedfileId = request.getFeedfileId(); + this.feedfileType = request.getFeedfileType(); + this.reason = reason; + this.successful = successful; + this.cancelled = cancelled; + this.reasonDetailed = reasonDetailed; + this.completionDate = new Date(); + } + + /** Constructor for creating new completed downloads. */ + public DownloadStatus(FeedFile feedfile, String title, DownloadError reason, + boolean successful, String reasonDetailed) { + Validate.notNull(feedfile); + + this.title = title; + this.done = true; + this.feedfileId = feedfile.getId(); + this.feedfileType = feedfile.getTypeAsInt(); + this.reason = reason; + this.successful = successful; + this.completionDate = new Date(); + this.reasonDetailed = reasonDetailed; + } + + /** Constructor for creating new completed downloads. */ + public DownloadStatus(long feedfileId, int feedfileType, String title, + DownloadError reason, boolean successful, String reasonDetailed) { + this.title = title; + this.done = true; + this.feedfileId = feedfileId; + this.feedfileType = feedfileType; + this.reason = reason; + this.successful = successful; + this.completionDate = new Date(); + this.reasonDetailed = reasonDetailed; + } + + @Override + public String toString() { + return "DownloadStatus [id=" + id + ", title=" + title + ", reason=" + + reason + ", reasonDetailed=" + reasonDetailed + + ", successful=" + successful + ", completionDate=" + + completionDate + ", feedfileId=" + feedfileId + + ", feedfileType=" + feedfileType + ", done=" + done + + ", cancelled=" + cancelled + "]"; + } + + public long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public DownloadError getReason() { + return reason; + } + + public String getReasonDetailed() { + return reasonDetailed; + } + + public boolean isSuccessful() { + return successful; + } + + public Date getCompletionDate() { + return (Date) completionDate.clone(); + } + + public long getFeedfileId() { + return feedfileId; + } + + public int getFeedfileType() { + return feedfileType; + } + + public boolean isDone() { + return done; + } + + public boolean isCancelled() { + return cancelled; + } + + public void setSuccessful() { + this.successful = true; + this.reason = DownloadError.SUCCESS; + this.done = true; + } + + public void setFailed(DownloadError reason, String reasonDetailed) { + this.successful = false; + this.reason = reason; + this.reasonDetailed = reasonDetailed; + this.done = true; + } + + public void setCancelled() { + this.successful = false; + this.reason = DownloadError.ERROR_DOWNLOAD_CANCELLED; + this.done = true; + this.cancelled = true; + } + + public void setCompletionDate(Date completionDate) { + this.completionDate = (Date) completionDate.clone(); + } + + public void setId(long id) { + this.id = id; + } +}
\ No newline at end of file diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java b/app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java new file mode 100644 index 000000000..80cc5b3f8 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.service.download; + +import android.content.Context; +import android.net.wifi.WifiManager; +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.R; + +import java.util.concurrent.Callable; + +/** Downloads files */ +public abstract class Downloader implements Callable<Downloader> { + private static final String TAG = "Downloader"; + + protected volatile boolean finished; + + protected volatile boolean cancelled; + + protected DownloadRequest request; + protected DownloadStatus result; + + public Downloader(DownloadRequest request) { + super(); + this.request = request; + this.request.setStatusMsg(R.string.download_pending); + this.cancelled = false; + this.result = new DownloadStatus(request, null, false, false, null); + } + + protected abstract void download(); + + public final Downloader call() { + WifiManager wifiManager = (WifiManager) PodcastApp.getInstance().getSystemService(Context.WIFI_SERVICE); + WifiManager.WifiLock wifiLock = null; + if (wifiManager != null) { + wifiLock = wifiManager.createWifiLock(TAG); + wifiLock.acquire(); + } + + download(); + + if (wifiLock != null) { + wifiLock.release(); + } + + if (result == null) { + throw new IllegalStateException( + "Downloader hasn't created DownloadStatus object"); + } + finished = true; + return this; + } + + public DownloadRequest getDownloadRequest() { + return request; + } + + public DownloadStatus getResult() { + return result; + } + + public boolean isFinished() { + return finished; + } + + public void cancel() { + cancelled = true; + } + +}
\ No newline at end of file diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java new file mode 100644 index 000000000..08420e83a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java @@ -0,0 +1,10 @@ +package de.danoeh.antennapod.service.download; + +/** + * Callback used by the Downloader-classes to notify the requester that the + * download has completed. + */ +public interface DownloaderCallback { + + public void onDownloadCompleted(Downloader downloader); +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java b/app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java new file mode 100644 index 000000000..7ae96dc07 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java @@ -0,0 +1,246 @@ +package de.danoeh.antennapod.service.download; + +import android.net.http.AndroidHttpClient; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.util.DownloadError; +import de.danoeh.antennapod.util.StorageUtils; +import de.danoeh.antennapod.util.URIUtil; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.message.BasicHeader; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; + +public class HttpDownloader extends Downloader { + private static final String TAG = "HttpDownloader"; + + private static final int BUFFER_SIZE = 8 * 1024; + + public HttpDownloader(DownloadRequest request) { + super(request); + } + + @Override + protected void download() { + File destination = new File(request.getDestination()); + final boolean fileExists = destination.exists(); + + if (request.isDeleteOnFailure() && fileExists) { + Log.w(TAG, "File already exists"); + if (request.getFeedfileType() != FeedImage.FEEDFILETYPE_FEEDIMAGE) { + onFail(DownloadError.ERROR_FILE_EXISTS, null); + return; + } else { + onSuccess(); + return; + } + } + + HttpClient httpClient = AntennapodHttpClient.getHttpClient(); + RandomAccessFile out = null; + InputStream connection = null; + try { + HttpGet httpGet = new HttpGet(URIUtil.getURIFromRequestUrl(request.getSource())); + + // add authentication information + String userInfo = httpGet.getURI().getUserInfo(); + if (userInfo != null) { + String[] parts = userInfo.split(":"); + if (parts.length == 2) { + httpGet.addHeader(BasicScheme.authenticate( + new UsernamePasswordCredentials(parts[0], parts[1]), + "UTF-8", false)); + } + } else if (!StringUtils.isEmpty(request.getUsername()) && request.getPassword() != null) { + httpGet.addHeader(BasicScheme.authenticate(new UsernamePasswordCredentials(request.getUsername(), + request.getPassword()), "UTF-8", false)); + } + + // add range header if necessary + if (fileExists) { + request.setSoFar(destination.length()); + httpGet.addHeader(new BasicHeader("Range", + "bytes=" + request.getSoFar() + "-")); + if (BuildConfig.DEBUG) Log.d(TAG, "Adding range header: " + request.getSoFar()); + } + + HttpResponse response = httpClient.execute(httpGet); + HttpEntity httpEntity = response.getEntity(); + int responseCode = response.getStatusLine().getStatusCode(); + Header contentEncodingHeader = response.getFirstHeader("Content-Encoding"); + + final boolean isGzip = contentEncodingHeader != null && + contentEncodingHeader.getValue().equalsIgnoreCase("gzip"); + + if (BuildConfig.DEBUG) + Log.d(TAG, "Response code is " + responseCode); + + if (responseCode / 100 != 2 || httpEntity == null) { + final DownloadError error; + final String details; + if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + error = DownloadError.ERROR_UNAUTHORIZED; + details = String.valueOf(responseCode); + } else { + error = DownloadError.ERROR_HTTP_DATA_ERROR; + details = String.valueOf(responseCode); + } + onFail(error, details); + return; + } + + if (!StorageUtils.storageAvailable(PodcastApp.getInstance())) { + onFail(DownloadError.ERROR_DEVICE_NOT_FOUND, null); + return; + } + + connection = new BufferedInputStream(AndroidHttpClient + .getUngzippedContent(httpEntity)); + + Header[] contentRangeHeaders = (fileExists) ? response.getHeaders("Content-Range") : null; + + if (fileExists && responseCode == HttpStatus.SC_PARTIAL_CONTENT + && contentRangeHeaders != null && contentRangeHeaders.length > 0) { + String start = contentRangeHeaders[0].getValue().substring("bytes ".length(), + contentRangeHeaders[0].getValue().indexOf("-")); + request.setSoFar(Long.valueOf(start)); + Log.d(TAG, "Starting download at position " + request.getSoFar()); + + out = new RandomAccessFile(destination, "rw"); + out.seek(request.getSoFar()); + } else { + destination.delete(); + destination.createNewFile(); + out = new RandomAccessFile(destination, "rw"); + } + + + byte[] buffer = new byte[BUFFER_SIZE]; + int count = 0; + request.setStatusMsg(R.string.download_running); + if (BuildConfig.DEBUG) + Log.d(TAG, "Getting size of download"); + request.setSize(httpEntity.getContentLength() + request.getSoFar()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Size is " + request.getSize()); + if (request.getSize() < 0) { + request.setSize(DownloadStatus.SIZE_UNKNOWN); + } + + long freeSpace = StorageUtils.getFreeSpaceAvailable(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Free space is " + freeSpace); + + if (request.getSize() != DownloadStatus.SIZE_UNKNOWN + && request.getSize() > freeSpace) { + onFail(DownloadError.ERROR_NOT_ENOUGH_SPACE, null); + return; + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting download"); + while (!cancelled + && (count = connection.read(buffer)) != -1) { + out.write(buffer, 0, count); + request.setSoFar(request.getSoFar() + count); + request.setProgressPercent((int) (((double) request + .getSoFar() / (double) request + .getSize()) * 100)); + } + if (cancelled) { + onCancelled(); + } else { + // check if size specified in the response header is the same as the size of the + // written file. This check cannot be made if compression was used + if (!isGzip && request.getSize() != DownloadStatus.SIZE_UNKNOWN && + request.getSoFar() != request.getSize()) { + onFail(DownloadError.ERROR_IO_ERROR, + "Download completed but size: " + + request.getSoFar() + + " does not equal expected size " + + request.getSize() + ); + return; + } + onSuccess(); + } + + } catch (IllegalArgumentException e) { + e.printStackTrace(); + onFail(DownloadError.ERROR_MALFORMED_URL, e.getMessage()); + } catch (SocketTimeoutException e) { + e.printStackTrace(); + onFail(DownloadError.ERROR_CONNECTION_ERROR, e.getMessage()); + } catch (UnknownHostException e) { + e.printStackTrace(); + onFail(DownloadError.ERROR_UNKNOWN_HOST, e.getMessage()); + } catch (IOException e) { + e.printStackTrace(); + onFail(DownloadError.ERROR_IO_ERROR, e.getMessage()); + } catch (NullPointerException e) { + // might be thrown by connection.getInputStream() + e.printStackTrace(); + onFail(DownloadError.ERROR_CONNECTION_ERROR, request.getSource()); + } finally { + IOUtils.closeQuietly(out); + AntennapodHttpClient.cleanup(); + } + } + + private void onSuccess() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Download was successful"); + result.setSuccessful(); + } + + private void onFail(DownloadError reason, String reasonDetailed) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Download failed"); + } + result.setFailed(reason, reasonDetailed); + if (request.isDeleteOnFailure()) { + cleanup(); + } + } + + private void onCancelled() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Download was cancelled"); + result.setCancelled(); + cleanup(); + } + + /** + * Deletes unfinished downloads. + */ + private void cleanup() { + if (request.getDestination() != null) { + File dest = new File(request.getDestination()); + if (dest.exists()) { + boolean rc = dest.delete(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Deleted file " + dest.getName() + "; Result: " + + rc); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "cleanup() didn't delete file: does not exist."); + } + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java new file mode 100644 index 000000000..59d7ddbb9 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java @@ -0,0 +1,1080 @@ +package de.danoeh.antennapod.service.playback; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioManager; +import android.media.MediaMetadataRetriever; +import android.media.MediaPlayer; +import android.media.RemoteControlClient; +import android.media.RemoteControlClient.MetadataEditor; +import android.os.AsyncTask; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.SurfaceHolder; +import android.widget.Toast; + +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.util.List; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.AudioplayerActivity; +import de.danoeh.antennapod.activity.VideoplayerActivity; +import de.danoeh.antennapod.asynctask.PicassoProvider; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.preferences.PlaybackPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.receiver.PlayerWidget; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.flattr.FlattrUtils; +import de.danoeh.antennapod.util.playback.Playable; + +/** + * Controls the MediaPlayer that plays a FeedMedia-file + */ +public class PlaybackService extends Service { + /** + * Logging tag + */ + private static final String TAG = "PlaybackService"; + + /** + * Parcelable of type Playable. + */ + public static final String EXTRA_PLAYABLE = "PlaybackService.PlayableExtra"; + /** + * True if media should be streamed. + */ + public static final String EXTRA_SHOULD_STREAM = "extra.de.danoeh.antennapod.service.shouldStream"; + /** + * True if playback should be started immediately after media has been + * prepared. + */ + public static final String EXTRA_START_WHEN_PREPARED = "extra.de.danoeh.antennapod.service.startWhenPrepared"; + + public static final String EXTRA_PREPARE_IMMEDIATELY = "extra.de.danoeh.antennapod.service.prepareImmediately"; + + public static final String ACTION_PLAYER_STATUS_CHANGED = "action.de.danoeh.antennapod.service.playerStatusChanged"; + private static final String AVRCP_ACTION_PLAYER_STATUS_CHANGED = "com.android.music.playstatechanged"; + private static final String AVRCP_ACTION_META_CHANGED = "com.android.music.metachanged"; + + public static final String ACTION_PLAYER_NOTIFICATION = "action.de.danoeh.antennapod.service.playerNotification"; + public static final String EXTRA_NOTIFICATION_CODE = "extra.de.danoeh.antennapod.service.notificationCode"; + public static final String EXTRA_NOTIFICATION_TYPE = "extra.de.danoeh.antennapod.service.notificationType"; + + /** + * If the PlaybackService receives this action, it will stop playback and + * try to shutdown. + */ + public static final String ACTION_SHUTDOWN_PLAYBACK_SERVICE = "action.de.danoeh.antennapod.service.actionShutdownPlaybackService"; + + /** + * If the PlaybackService receives this action, it will end playback of the + * current episode and load the next episode if there is one available. + */ + public static final String ACTION_SKIP_CURRENT_EPISODE = "action.de.danoeh.antennapod.service.skipCurrentEpisode"; + + /** + * Used in NOTIFICATION_TYPE_RELOAD. + */ + public static final int EXTRA_CODE_AUDIO = 1; + public static final int EXTRA_CODE_VIDEO = 2; + + public static final int NOTIFICATION_TYPE_ERROR = 0; + public static final int NOTIFICATION_TYPE_INFO = 1; + public static final int NOTIFICATION_TYPE_BUFFER_UPDATE = 2; + + /** + * Receivers of this intent should update their information about the curently playing media + */ + public static final int NOTIFICATION_TYPE_RELOAD = 3; + /** + * The state of the sleeptimer changed. + */ + public static final int NOTIFICATION_TYPE_SLEEPTIMER_UPDATE = 4; + public static final int NOTIFICATION_TYPE_BUFFER_START = 5; + public static final int NOTIFICATION_TYPE_BUFFER_END = 6; + /** + * No more episodes are going to be played. + */ + public static final int NOTIFICATION_TYPE_PLAYBACK_END = 7; + + /** + * Playback speed has changed + */ + public static final int NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE = 8; + + /** + * Returned by getPositionSafe() or getDurationSafe() if the playbackService + * is in an invalid state. + */ + public static final int INVALID_TIME = -1; + + /** + * Is true if service is running. + */ + public static boolean isRunning = false; + /** + * Is true if service has received a valid start command. + */ + public static boolean started = false; + + private static final int NOTIFICATION_ID = 1; + + private RemoteControlClient remoteControlClient; + private PlaybackServiceMediaPlayer mediaPlayer; + private PlaybackServiceTaskManager taskManager; + + private static volatile MediaType currentMediaType = MediaType.UNKNOWN; + + private final IBinder mBinder = new LocalBinder(); + + public class LocalBinder extends Binder { + public PlaybackService getService() { + return PlaybackService.this; + } + } + + @Override + public boolean onUnbind(Intent intent) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received onUnbind event"); + return super.onUnbind(intent); + } + + /** + * Returns an intent which starts an audio- or videoplayer, depending on the + * type of media that is being played. If the playbackservice is not + * running, the type of the last played media will be looked up. + */ + public static Intent getPlayerActivityIntent(Context context) { + if (isRunning) { + if (currentMediaType == MediaType.VIDEO) { + return new Intent(context, VideoplayerActivity.class); + } else { + return new Intent(context, AudioplayerActivity.class); + } + } else { + if (PlaybackPreferences.getCurrentEpisodeIsVideo()) { + return new Intent(context, VideoplayerActivity.class); + } else { + return new Intent(context, AudioplayerActivity.class); + } + } + } + + /** + * Same as getPlayerActivityIntent(context), but here the type of activity + * depends on the FeedMedia that is provided as an argument. + */ + public static Intent getPlayerActivityIntent(Context context, Playable media) { + MediaType mt = media.getMediaType(); + if (mt == MediaType.VIDEO) { + return new Intent(context, VideoplayerActivity.class); + } else { + return new Intent(context, AudioplayerActivity.class); + } + } + + @SuppressLint("NewApi") + @Override + public void onCreate() { + super.onCreate(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Service created."); + isRunning = true; + + registerReceiver(headsetDisconnected, new IntentFilter( + Intent.ACTION_HEADSET_PLUG)); + registerReceiver(shutdownReceiver, new IntentFilter( + ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + registerReceiver(audioBecomingNoisy, new IntentFilter( + AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + registerReceiver(skipCurrentEpisodeReceiver, new IntentFilter( + ACTION_SKIP_CURRENT_EPISODE)); + remoteControlClient = setupRemoteControlClient(); + taskManager = new PlaybackServiceTaskManager(this, taskManagerCallback); + mediaPlayer = new PlaybackServiceMediaPlayer(this, mediaPlayerCallback); + + } + + @SuppressLint("NewApi") + @Override + public void onDestroy() { + super.onDestroy(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Service is about to be destroyed"); + isRunning = false; + started = false; + currentMediaType = MediaType.UNKNOWN; + + unregisterReceiver(headsetDisconnected); + unregisterReceiver(shutdownReceiver); + unregisterReceiver(audioBecomingNoisy); + unregisterReceiver(skipCurrentEpisodeReceiver); + mediaPlayer.shutdown(); + taskManager.shutdown(); + } + + @Override + public IBinder onBind(Intent intent) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received onBind event"); + return mBinder; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + + if (BuildConfig.DEBUG) + Log.d(TAG, "OnStartCommand called"); + final int keycode = intent.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1); + final Playable playable = intent.getParcelableExtra(EXTRA_PLAYABLE); + if (keycode == -1 && playable == null) { + Log.e(TAG, "PlaybackService was started with no arguments"); + stopSelf(); + } + + if ((flags & Service.START_FLAG_REDELIVERY) != 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "onStartCommand is a redelivered intent, calling stopForeground now."); + stopForeground(true); + } else { + + if (keycode != -1) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received media button event"); + handleKeycode(keycode); + } else { + started = true; + boolean stream = intent.getBooleanExtra(EXTRA_SHOULD_STREAM, + true); + boolean startWhenPrepared = intent.getBooleanExtra(EXTRA_START_WHEN_PREPARED, false); + boolean prepareImmediately = intent.getBooleanExtra(EXTRA_PREPARE_IMMEDIATELY, false); + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + mediaPlayer.playMediaObject(playable, stream, startWhenPrepared, prepareImmediately); + } + } + + return Service.START_REDELIVER_INTENT; + } + + /** + * Handles media button events + */ + private void handleKeycode(int keycode) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Handling keycode: " + keycode); + + final PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo(); + final PlayerStatus status = info.playerStatus; + switch (keycode) { + case KeyEvent.KEYCODE_HEADSETHOOK: + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + if (status == PlayerStatus.PLAYING) { + mediaPlayer.pause(true, true); + } else if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { + mediaPlayer.resume(); + } else if (status == PlayerStatus.PREPARING) { + mediaPlayer.setStartWhenPrepared(!mediaPlayer.isStartWhenPrepared()); + } else if (status == PlayerStatus.INITIALIZED) { + mediaPlayer.setStartWhenPrepared(true); + mediaPlayer.prepare(); + } + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { + mediaPlayer.resume(); + } else if (status == PlayerStatus.INITIALIZED) { + mediaPlayer.setStartWhenPrepared(true); + mediaPlayer.prepare(); + } + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + if (status == PlayerStatus.PLAYING) { + mediaPlayer.pause(true, true); + } + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + mediaPlayer.seekDelta(UserPreferences.getSeekDeltaMs()); + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_REWIND: + mediaPlayer.seekDelta(-UserPreferences.getSeekDeltaMs()); + break; + default: + if (info.playable != null && info.playerStatus == PlayerStatus.PLAYING) { // only notify the user about an unknown key event if it is actually doing something + String message = String.format(getResources().getString(R.string.unknown_media_key), keycode); + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } + break; + } + } + + /** + * Called by a mediaplayer Activity as soon as it has prepared its + * mediaplayer. + */ + public void setVideoSurface(SurfaceHolder sh) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting display"); + mediaPlayer.setVideoSurface(sh); + } + + /** + * Called when the surface holder of the mediaplayer has to be changed. + */ + private void resetVideoSurface() { + taskManager.cancelPositionSaver(); + mediaPlayer.resetVideoSurface(); + } + + public void notifyVideoSurfaceAbandoned() { + stopForeground(true); + mediaPlayer.resetVideoSurface(); + } + + private final PlaybackServiceTaskManager.PSTMCallback taskManagerCallback = new PlaybackServiceTaskManager.PSTMCallback() { + @Override + public void positionSaverTick() { + saveCurrentPosition(true, PlaybackServiceTaskManager.POSITION_SAVER_WAITING_INTERVAL); + } + + @Override + public void onSleepTimerExpired() { + mediaPlayer.pause(true, true); + sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); + } + + + @Override + public void onWidgetUpdaterTick() { + updateWidget(); + } + + @Override + public void onChapterLoaded(Playable media) { + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + } + }; + + private final PlaybackServiceMediaPlayer.PSMPCallback mediaPlayerCallback = new PlaybackServiceMediaPlayer.PSMPCallback() { + @Override + public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + currentMediaType = mediaPlayer.getCurrentMediaType(); + switch (newInfo.playerStatus) { + case INITIALIZED: + writePlaybackPreferences(); + break; + + case PREPARED: + taskManager.startChapterLoader(newInfo.playable); + break; + + case PAUSED: + taskManager.cancelPositionSaver(); + saveCurrentPosition(false, 0); + taskManager.cancelWidgetUpdater(); + stopForeground(true); + break; + + case STOPPED: + //setCurrentlyPlayingMedia(PlaybackPreferences.NO_MEDIA_PLAYING); + //stopSelf(); + break; + + case PLAYING: + if (BuildConfig.DEBUG) + Log.d(TAG, "Audiofocus successfully requested"); + if (BuildConfig.DEBUG) + Log.d(TAG, "Resuming/Starting playback"); + + taskManager.startPositionSaver(); + taskManager.startWidgetUpdater(); + setupNotification(newInfo); + break; + case ERROR: + writePlaybackPreferencesNoMediaPlaying(); + break; + + } + + sendBroadcast(new Intent(ACTION_PLAYER_STATUS_CHANGED)); + updateWidget(); + refreshRemoteControlClientState(newInfo); + bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED); + bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED); + } + + @Override + public void shouldStop() { + stopSelf(); + } + + @Override + public void playbackSpeedChanged(float s) { + sendNotificationBroadcast( + NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE, 0); + } + + @Override + public void onBufferingUpdate(int percent) { + sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_UPDATE, percent); + } + + @Override + public boolean onMediaPlayerInfo(int code) { + switch (code) { + case MediaPlayer.MEDIA_INFO_BUFFERING_START: + sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_START, 0); + return true; + case MediaPlayer.MEDIA_INFO_BUFFERING_END: + sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_END, 0); + return true; + default: + return false; + } + } + + @Override + public boolean onMediaPlayerError(Object inObj, int what, int extra) { + final String TAG = "PlaybackService.onErrorListener"; + Log.w(TAG, "An error has occured: " + what + " " + extra); + if (mediaPlayer.getPSMPInfo().playerStatus == PlayerStatus.PLAYING) { + mediaPlayer.pause(true, false); + } + sendNotificationBroadcast(NOTIFICATION_TYPE_ERROR, what); + writePlaybackPreferencesNoMediaPlaying(); + stopSelf(); + return true; + } + + @Override + public boolean endPlayback(boolean playNextEpisode) { + PlaybackService.this.endPlayback(true); + return true; + } + + @Override + public RemoteControlClient getRemoteControlClient() { + return remoteControlClient; + } + }; + + private void endPlayback(boolean playNextEpisode) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Playback ended"); + + final Playable media = mediaPlayer.getPSMPInfo().playable; + if (media == null) { + Log.e(TAG, "Cannot end playback: media was null"); + return; + } + + taskManager.cancelPositionSaver(); + + boolean isInQueue = false; + FeedItem nextItem = null; + + if (media instanceof FeedMedia) { + FeedItem item = ((FeedMedia) media).getItem(); + DBWriter.markItemRead(PlaybackService.this, item, true, true); + + try { + final List<FeedItem> queue = taskManager.getQueue(); + isInQueue = QueueAccess.ItemListAccess(queue).contains(((FeedMedia) media).getItem().getId()); + nextItem = DBTasks.getQueueSuccessorOfItem(this, item.getId(), queue); + } catch (InterruptedException e) { + e.printStackTrace(); + // isInQueue remains false + } + if (isInQueue) { + DBWriter.removeQueueItem(PlaybackService.this, item.getId(), true); + } + DBWriter.addItemToPlaybackHistory(PlaybackService.this, (FeedMedia) media); + + // auto-flattr if enabled + if (isAutoFlattrable(media) && UserPreferences.getAutoFlattrPlayedDurationThreshold() == 1.0f) { + DBTasks.flattrItemIfLoggedIn(PlaybackService.this, item); + } + } + + // Load next episode if previous episode was in the queue and if there + // is an episode in the queue left. + // Start playback immediately if continuous playback is enabled + Playable nextMedia = null; + boolean loadNextItem = isInQueue && nextItem != null; + playNextEpisode = playNextEpisode && loadNextItem + && UserPreferences.isFollowQueue(); + if (loadNextItem) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading next item in queue"); + nextMedia = nextItem.getMedia(); + } + final boolean prepareImmediately; + final boolean startWhenPrepared; + final boolean stream; + + if (playNextEpisode) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Playback of next episode will start immediately."); + prepareImmediately = startWhenPrepared = true; + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "No more episodes available to play"); + + prepareImmediately = startWhenPrepared = false; + stopForeground(true); + stopWidgetUpdater(); + } + + writePlaybackPreferencesNoMediaPlaying(); + if (nextMedia != null) { + stream = !media.localFileAvailable(); + mediaPlayer.playMediaObject(nextMedia, stream, startWhenPrepared, prepareImmediately); + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, + (nextMedia.getMediaType() == MediaType.VIDEO) ? EXTRA_CODE_VIDEO : EXTRA_CODE_AUDIO); + } else { + sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); + mediaPlayer.stop(); + //stopSelf(); + } + } + + public void setSleepTimer(long waitingTime) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting sleep timer to " + Long.toString(waitingTime) + + " milliseconds"); + taskManager.setSleepTimer(waitingTime); + sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); + } + + public void disableSleepTimer() { + taskManager.disableSleepTimer(); + sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); + } + + private void writePlaybackPreferencesNoMediaPlaying() { + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()).edit(); + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.commit(); + } + + + private void writePlaybackPreferences() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Writing playback preferences"); + + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()).edit(); + PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo(); + MediaType mediaType = mediaPlayer.getCurrentMediaType(); + boolean stream = mediaPlayer.isStreaming(); + + if (info.playable != null) { + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA, + info.playable.getPlayableType()); + editor.putBoolean( + PlaybackPreferences.PREF_CURRENT_EPISODE_IS_STREAM, + stream); + editor.putBoolean( + PlaybackPreferences.PREF_CURRENT_EPISODE_IS_VIDEO, + mediaType == MediaType.VIDEO); + if (info.playable instanceof FeedMedia) { + FeedMedia fMedia = (FeedMedia) info.playable; + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + fMedia.getItem().getFeed().getId()); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + fMedia.getId()); + } else { + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + } + info.playable.writeToPreferences(editor); + } else { + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + } + + editor.commit(); + } + + /** + * Send ACTION_PLAYER_STATUS_CHANGED without changing the status attribute. + */ + private void postStatusUpdateIntent() { + sendBroadcast(new Intent(ACTION_PLAYER_STATUS_CHANGED)); + } + + private void sendNotificationBroadcast(int type, int code) { + Intent intent = new Intent(ACTION_PLAYER_NOTIFICATION); + intent.putExtra(EXTRA_NOTIFICATION_TYPE, type); + intent.putExtra(EXTRA_NOTIFICATION_CODE, code); + sendBroadcast(intent); + } + + /** + * Used by setupNotification to load notification data in another thread. + */ + private AsyncTask<Void, Void, Void> notificationSetupTask; + + /** + * Prepares notification and starts the service in the foreground. + */ + @SuppressLint("NewApi") + private void setupNotification(final PlaybackServiceMediaPlayer.PSMPInfo info) { + final PendingIntent pIntent = PendingIntent.getActivity(this, 0, + PlaybackService.getPlayerActivityIntent(this), + PendingIntent.FLAG_UPDATE_CURRENT); + + if (notificationSetupTask != null) { + notificationSetupTask.cancel(true); + } + notificationSetupTask = new AsyncTask<Void, Void, Void>() { + Bitmap icon = null; + + @Override + protected Void doInBackground(Void... params) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting background work"); + if (android.os.Build.VERSION.SDK_INT >= 11) { + if (info.playable != null) { + try { + int iconSize = getResources().getDimensionPixelSize( + android.R.dimen.notification_large_icon_width); + icon = PicassoProvider.getMediaMetadataPicassoInstance(PlaybackService.this) + .load(info.playable.getImageUri()) + .resize(iconSize, iconSize) + .get(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + } + if (icon == null) { + icon = BitmapFactory.decodeResource(getResources(), + R.drawable.ic_stat_antenna); + } + + return null; + } + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + if (!isCancelled() && info.playerStatus == PlayerStatus.PLAYING + && info.playable != null) { + String contentText = info.playable.getFeedTitle(); + String contentTitle = info.playable.getEpisodeTitle(); + Notification notification = null; + if (android.os.Build.VERSION.SDK_INT >= 16) { + Intent pauseButtonIntent = new Intent( + PlaybackService.this, PlaybackService.class); + pauseButtonIntent.putExtra( + MediaButtonReceiver.EXTRA_KEYCODE, + KeyEvent.KEYCODE_MEDIA_PAUSE); + PendingIntent pauseButtonPendingIntent = PendingIntent + .getService(PlaybackService.this, 0, + pauseButtonIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + Notification.Builder notificationBuilder = new Notification.Builder( + PlaybackService.this) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setOngoing(true) + .setContentIntent(pIntent) + .setLargeIcon(icon) + .setSmallIcon(R.drawable.ic_stat_antenna) + .addAction(android.R.drawable.ic_media_pause, + getString(R.string.pause_label), + pauseButtonPendingIntent); + notification = notificationBuilder.build(); + } else { + NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder( + PlaybackService.this) + .setContentTitle(contentTitle) + .setContentText(contentText).setOngoing(true) + .setContentIntent(pIntent).setLargeIcon(icon) + .setSmallIcon(R.drawable.ic_stat_antenna); + notification = notificationBuilder.getNotification(); + } + startForeground(NOTIFICATION_ID, notification); + if (BuildConfig.DEBUG) + Log.d(TAG, "Notification set up"); + } + } + + }; + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) { + notificationSetupTask + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + notificationSetupTask.execute(); + } + + } + + /** + * Saves the current position of the media file to the DB + * + * @param updatePlayedDuration true if played_duration should be updated. This applies only to FeedMedia objects + * @param deltaPlayedDuration value by which played_duration should be increased. + */ + private synchronized void saveCurrentPosition(boolean updatePlayedDuration, int deltaPlayedDuration) { + int position = getCurrentPosition(); + int duration = getDuration(); + float playbackSpeed = getCurrentPlaybackSpeed(); + final Playable playable = mediaPlayer.getPSMPInfo().playable; + if (position != INVALID_TIME && duration != INVALID_TIME && playable != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Saving current position to " + position); + if (updatePlayedDuration && playable instanceof FeedMedia) { + FeedMedia m = (FeedMedia) playable; + FeedItem item = m.getItem(); + m.setPlayedDuration(m.getPlayedDuration() + ((int) (deltaPlayedDuration * playbackSpeed))); + // Auto flattr + if (isAutoFlattrable(m) && + (m.getPlayedDuration() > UserPreferences.getAutoFlattrPlayedDurationThreshold() * duration)) { + + if (BuildConfig.DEBUG) + Log.d(TAG, "saveCurrentPosition: performing auto flattr since played duration " + Integer.toString(m.getPlayedDuration()) + + " is " + UserPreferences.getAutoFlattrPlayedDurationThreshold() * 100 + "% of file duration " + Integer.toString(duration)); + DBTasks.flattrItemIfLoggedIn(this, item); + } + } + playable.saveCurrentPosition(PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()), + position + ); + } + } + + private void stopWidgetUpdater() { + taskManager.cancelWidgetUpdater(); + sendBroadcast(new Intent(PlayerWidget.STOP_WIDGET_UPDATE)); + } + + private void updateWidget() { + PlaybackService.this.sendBroadcast(new Intent( + PlayerWidget.FORCE_WIDGET_UPDATE)); + } + + public boolean sleepTimerActive() { + return taskManager.isSleepTimerActive(); + } + + public long getSleepTimerTimeLeft() { + return taskManager.getSleepTimerTimeLeft(); + } + + @SuppressLint("NewApi") + private RemoteControlClient setupRemoteControlClient() { + if (Build.VERSION.SDK_INT < 14) { + return null; + } + + Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + mediaButtonIntent.setComponent(new ComponentName(getPackageName(), + MediaButtonReceiver.class.getName())); + PendingIntent mediaPendingIntent = PendingIntent.getBroadcast( + getApplicationContext(), 0, mediaButtonIntent, 0); + remoteControlClient = new RemoteControlClient(mediaPendingIntent); + int controlFlags; + if (android.os.Build.VERSION.SDK_INT < 16) { + controlFlags = RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE + | RemoteControlClient.FLAG_KEY_MEDIA_NEXT; + } else { + controlFlags = RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE; + } + remoteControlClient.setTransportControlFlags(controlFlags); + return remoteControlClient; + } + + /** + * Refresh player status and metadata. + */ + @SuppressLint("NewApi") + private void refreshRemoteControlClientState(PlaybackServiceMediaPlayer.PSMPInfo info) { + if (android.os.Build.VERSION.SDK_INT >= 14) { + if (remoteControlClient != null) { + switch (info.playerStatus) { + case PLAYING: + remoteControlClient + .setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING); + break; + case PAUSED: + case INITIALIZED: + remoteControlClient + .setPlaybackState(RemoteControlClient.PLAYSTATE_PAUSED); + break; + case STOPPED: + remoteControlClient + .setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED); + break; + case ERROR: + remoteControlClient + .setPlaybackState(RemoteControlClient.PLAYSTATE_ERROR); + break; + default: + remoteControlClient + .setPlaybackState(RemoteControlClient.PLAYSTATE_BUFFERING); + } + if (info.playable != null) { + MetadataEditor editor = remoteControlClient + .editMetadata(false); + editor.putString(MediaMetadataRetriever.METADATA_KEY_TITLE, + info.playable.getEpisodeTitle()); + + editor.putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, + info.playable.getFeedTitle()); + + editor.apply(); + } + if (BuildConfig.DEBUG) + Log.d(TAG, "RemoteControlClient state was refreshed"); + } + } + } + + private void bluetoothNotifyChange(PlaybackServiceMediaPlayer.PSMPInfo info, String whatChanged) { + boolean isPlaying = false; + + if (info.playerStatus == PlayerStatus.PLAYING) { + isPlaying = true; + } + + if (info.playable != null) { + Intent i = new Intent(whatChanged); + i.putExtra("id", 1); + i.putExtra("artist", ""); + i.putExtra("album", info.playable.getFeedTitle()); + i.putExtra("track", info.playable.getEpisodeTitle()); + i.putExtra("playing", isPlaying); + final List<FeedItem> queue = taskManager.getQueueIfLoaded(); + if (queue != null) { + i.putExtra("ListSize", queue.size()); + } + i.putExtra("duration", info.playable.getDuration()); + i.putExtra("position", info.playable.getPosition()); + sendBroadcast(i); + } + } + + /** + * Pauses playback when the headset is disconnected and the preference is + * set + */ + private BroadcastReceiver headsetDisconnected = new BroadcastReceiver() { + private static final String TAG = "headsetDisconnected"; + private static final int UNPLUGGED = 0; + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), Intent.ACTION_HEADSET_PLUG)) { + int state = intent.getIntExtra("state", -1); + if (state != -1) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Headset plug event. State is " + state); + if (state == UNPLUGGED) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Headset was unplugged during playback."); + pauseIfPauseOnDisconnect(); + } + } else { + Log.e(TAG, "Received invalid ACTION_HEADSET_PLUG intent"); + } + } + } + }; + + private BroadcastReceiver audioBecomingNoisy = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + // sound is about to change, eg. bluetooth -> speaker + if (BuildConfig.DEBUG) + Log.d(TAG, "Pausing playback because audio is becoming noisy"); + pauseIfPauseOnDisconnect(); + } + // android.media.AUDIO_BECOMING_NOISY + }; + + /** + * Pauses playback if PREF_PAUSE_ON_HEADSET_DISCONNECT was set to true. + */ + private void pauseIfPauseOnDisconnect() { + if (UserPreferences.isPauseOnHeadsetDisconnect()) { + mediaPlayer.pause(true, true); + } + } + + private BroadcastReceiver shutdownReceiver = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), ACTION_SHUTDOWN_PLAYBACK_SERVICE)) { + stopSelf(); + } + } + + }; + + private BroadcastReceiver skipCurrentEpisodeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), ACTION_SKIP_CURRENT_EPISODE)) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received SKIP_CURRENT_EPISODE intent"); + mediaPlayer.endPlayback(); + } + } + }; + + public static MediaType getCurrentMediaType() { + return currentMediaType; + } + + public void resume() { + mediaPlayer.resume(); + } + + public void prepare() { + mediaPlayer.prepare(); + } + + public void pause(boolean abandonAudioFocus, boolean reinit) { + mediaPlayer.pause(abandonAudioFocus, reinit); + } + + public void reinit() { + mediaPlayer.reinit(); + } + + public PlaybackServiceMediaPlayer.PSMPInfo getPSMPInfo() { + return mediaPlayer.getPSMPInfo(); + } + + public PlayerStatus getStatus() { + return mediaPlayer.getPSMPInfo().playerStatus; + } + + public Playable getPlayable() { + return mediaPlayer.getPSMPInfo().playable; + } + + public void setSpeed(float speed) { + mediaPlayer.setSpeed(speed); + } + + public boolean canSetSpeed() { + return mediaPlayer.canSetSpeed(); + } + + public float getCurrentPlaybackSpeed() { + return mediaPlayer.getPlaybackSpeed(); + } + + public boolean isStartWhenPrepared() { + return mediaPlayer.isStartWhenPrepared(); + } + + public void setStartWhenPrepared(boolean s) { + mediaPlayer.setStartWhenPrepared(s); + } + + + public void seekTo(final int t) { + mediaPlayer.seekTo(t); + } + + + public void seekDelta(final int d) { + mediaPlayer.seekDelta(d); + } + + /** + * @see de.danoeh.antennapod.service.playback.PlaybackServiceMediaPlayer#seekToChapter(de.danoeh.antennapod.feed.Chapter) + */ + public void seekToChapter(Chapter c) { + mediaPlayer.seekToChapter(c); + } + + /** + * call getDuration() on mediaplayer or return INVALID_TIME if player is in + * an invalid state. + */ + public int getDuration() { + return mediaPlayer.getDuration(); + } + + /** + * call getCurrentPosition() on mediaplayer or return INVALID_TIME if player + * is in an invalid state. + */ + public int getCurrentPosition() { + return mediaPlayer.getPosition(); + } + + public boolean isStreaming() { + return mediaPlayer.isStreaming(); + } + + public Pair<Integer, Integer> getVideoSize() { + return mediaPlayer.getVideoSize(); + } + + private boolean isAutoFlattrable(Playable p) { + if (p != null && p instanceof FeedMedia) { + FeedMedia media = (FeedMedia) p; + FeedItem item = ((FeedMedia) p).getItem(); + return item != null && FlattrUtils.hasToken() && UserPreferences.isAutoFlattr() && item.getPaymentLink() != null && item.getFlattrStatus().getUnflattred(); + } else { + return false; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java new file mode 100644 index 000000000..49f20012d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java @@ -0,0 +1,979 @@ +package de.danoeh.antennapod.service.playback; + +import android.content.ComponentName; +import android.content.Context; +import android.media.AudioManager; +import android.media.RemoteControlClient; +import android.net.wifi.WifiManager; +import android.os.PowerManager; +import android.telephony.TelephonyManager; +import android.util.Log; +import android.util.Pair; +import android.view.SurfaceHolder; + +import org.apache.commons.lang3.Validate; + +import java.io.IOException; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.util.playback.AudioPlayer; +import de.danoeh.antennapod.util.playback.IPlayer; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.util.playback.VideoPlayer; + +/** + * Manages the MediaPlayer object of the PlaybackService. + */ +public class PlaybackServiceMediaPlayer { + public static final String TAG = "PlaybackServiceMediaPlayer"; + + /** + * Return value of some PSMP methods if the method call failed. + */ + public static final int INVALID_TIME = -1; + + private final AudioManager audioManager; + + private volatile PlayerStatus playerStatus; + private volatile PlayerStatus statusBeforeSeeking; + private volatile IPlayer mediaPlayer; + private volatile Playable media; + + private volatile boolean stream; + private volatile MediaType mediaType; + private volatile AtomicBoolean startWhenPrepared; + private volatile boolean pausedBecauseOfTransientAudiofocusLoss; + private volatile Pair<Integer, Integer> videoSize; + + /** + * Some asynchronous calls might change the state of the MediaPlayer object. Therefore calls in other threads + * have to wait until these operations have finished. + */ + private final ReentrantLock playerLock; + + private final PSMPCallback callback; + private final Context context; + + private final ThreadPoolExecutor executor; + + /** + * A wifi-lock that is acquired if the media file is being streamed. + */ + private WifiManager.WifiLock wifiLock; + + public PlaybackServiceMediaPlayer(Context context, PSMPCallback callback) { + Validate.notNull(context); + Validate.notNull(callback); + + this.context = context; + this.callback = callback; + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + this.playerLock = new ReentrantLock(); + this.startWhenPrepared = new AtomicBoolean(false); + executor = new ThreadPoolExecutor(1, 1, 5, TimeUnit.MINUTES, new LinkedBlockingDeque<Runnable>(), + new RejectedExecutionHandler() { + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + if (BuildConfig.DEBUG) Log.d(TAG, "Rejected execution of runnable"); + } + } + ); + + mediaPlayer = null; + statusBeforeSeeking = null; + pausedBecauseOfTransientAudiofocusLoss = false; + mediaType = MediaType.UNKNOWN; + playerStatus = PlayerStatus.STOPPED; + videoSize = null; + } + + /** + * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing + * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will + * not do anything. + * Whether playback starts immediately depends on the given parameters. See below for more details. + * <p/> + * States: + * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. + * <p/> + * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If + * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. + * <p/> + * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object + * will enter the ERROR state. + * <p/> + * This method is executed on an internal executor service. + * + * @param playable The Playable object that is supposed to be played. This parameter must not be null. + * @param stream The type of playback. If false, the Playable object MUST provide access to a locally available file via + * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by + * the Android MediaPlayer via getStreamUrl. + * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the + * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared + * for playback immediately (see 'prepareImmediately' parameter for more details) + * @param prepareImmediately Set to true if the method should also prepare the episode for playback. + */ + public void playMediaObject(final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { + Validate.notNull(playable); + + if (BuildConfig.DEBUG) Log.d(TAG, "Play media object."); + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + try { + playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately); + } catch (RuntimeException e) { + e.printStackTrace(); + throw e; + } finally { + playerLock.unlock(); + } + } + }); + } + + /** + * Internal implementation of playMediaObject. This method has an additional parameter that allows the caller to force a media player reset even if + * the given playable parameter is the same object as the currently playing media. + * <p/> + * This method requires the playerLock and is executed on the caller's thread. + * + * @see #playMediaObject(de.danoeh.antennapod.util.playback.Playable, boolean, boolean, boolean) + */ + private void playMediaObject(final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { + Validate.notNull(playable); + if (!playerLock.isHeldByCurrentThread()) + throw new IllegalStateException("method requires playerLock"); + + + if (media != null) { + if (!forceReset && media.getIdentifier().equals(playable.getIdentifier())) { + // episode is already playing -> ignore method call + if (BuildConfig.DEBUG) + Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing."); + return; + } else { + // stop playback of this episode + if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARED) { + mediaPlayer.stop(); + } + setPlayerStatus(PlayerStatus.INDETERMINATE, null); + } + } + + this.media = playable; + this.stream = stream; + this.mediaType = media.getMediaType(); + this.videoSize = null; + createMediaPlayer(); + PlaybackServiceMediaPlayer.this.startWhenPrepared.set(startWhenPrepared); + setPlayerStatus(PlayerStatus.INITIALIZING, media); + try { + media.loadMetadata(); + if (stream) { + mediaPlayer.setDataSource(media.getStreamUrl()); + } else { + mediaPlayer.setDataSource(media.getLocalMediaUrl()); + } + setPlayerStatus(PlayerStatus.INITIALIZED, media); + + if (mediaType == MediaType.VIDEO) { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + // vp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT); + } + + if (prepareImmediately) { + setPlayerStatus(PlayerStatus.PREPARING, media); + mediaPlayer.prepare(); + onPrepared(startWhenPrepared); + } + + } catch (Playable.PlayableException e) { + e.printStackTrace(); + setPlayerStatus(PlayerStatus.ERROR, null); + } catch (IOException e) { + e.printStackTrace(); + setPlayerStatus(PlayerStatus.ERROR, null); + } catch (IllegalStateException e) { + e.printStackTrace(); + setPlayerStatus(PlayerStatus.ERROR, null); + } + } + + + /** + * Resumes playback if the PSMP object is in PREPARED or PAUSED state. If the PSMP object is in an invalid state. + * nothing will happen. + * <p/> + * This method is executed on an internal executor service. + */ + public void resume() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + resumeSync(); + playerLock.unlock(); + } + }); + } + + private void resumeSync() { + if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) { + int focusGained = audioManager.requestAudioFocus( + audioFocusChangeListener, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + if (focusGained == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + acquireWifiLockIfNecessary(); + setSpeed(Float.parseFloat(UserPreferences.getPlaybackSpeed())); + mediaPlayer.start(); + if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { + mediaPlayer.seekTo(media.getPosition()); + } + + setPlayerStatus(PlayerStatus.PLAYING, media); + pausedBecauseOfTransientAudiofocusLoss = false; + if (android.os.Build.VERSION.SDK_INT >= 14) { + RemoteControlClient remoteControlClient = callback.getRemoteControlClient(); + if (remoteControlClient != null) { + audioManager + .registerRemoteControlClient(remoteControlClient); + } + } + audioManager + .registerMediaButtonEventReceiver(new ComponentName(context.getPackageName(), + MediaButtonReceiver.class.getName())); + media.onPlaybackStart(); + + } else { + if (BuildConfig.DEBUG) Log.e(TAG, "Failed to request audio focus"); + } + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Call to resume() was ignored because current state of PSMP object is " + playerStatus); + } + } + + + /** + * Saves the current position and pauses playback. Note that, if audiofocus + * is abandoned, the lockscreen controls will also disapear. + * <p/> + * This method is executed on an internal executor service. + * + * @param abandonFocus is true if the service should release audio focus + * @param reinit is true if service should reinit after pausing if the media + * file is being streamed + */ + public void pause(final boolean abandonFocus, final boolean reinit) { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + releaseWifiLockIfNecessary(); + if (playerStatus == PlayerStatus.PLAYING) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Pausing playback."); + mediaPlayer.pause(); + setPlayerStatus(PlayerStatus.PAUSED, media); + + if (abandonFocus) { + audioManager.abandonAudioFocus(audioFocusChangeListener); + pausedBecauseOfTransientAudiofocusLoss = false; + } + if (stream && reinit) { + reinit(); + } + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Ignoring call to pause: Player is in " + playerStatus + " state"); + } + + playerLock.unlock(); + } + }); + } + + /** + * Prepared media player for playback if the service is in the INITALIZED + * state. + * <p/> + * This method is executed on an internal executor service. + */ + public void prepare() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + + if (playerStatus == PlayerStatus.INITIALIZED) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Preparing media player"); + setPlayerStatus(PlayerStatus.PREPARING, media); + try { + mediaPlayer.prepare(); + onPrepared(startWhenPrepared.get()); + } catch (IOException e) { + e.printStackTrace(); + setPlayerStatus(PlayerStatus.ERROR, null); + } + } + playerLock.unlock(); + + } + }); + } + + /** + * Called after media player has been prepared. This method is executed on the caller's thread. + */ + void onPrepared(final boolean startWhenPrepared) { + playerLock.lock(); + + if (playerStatus != PlayerStatus.PREPARING) { + playerLock.unlock(); + throw new IllegalStateException("Player is not in PREPARING state"); + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "Resource prepared"); + + if (mediaType == MediaType.VIDEO) { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + videoSize = new Pair<Integer, Integer>(vp.getVideoWidth(), vp.getVideoHeight()); + } + + if (media.getPosition() > 0) { + mediaPlayer.seekTo(media.getPosition()); + } + + if (media.getDuration() == 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting duration of media"); + media.setDuration(mediaPlayer.getDuration()); + } + setPlayerStatus(PlayerStatus.PREPARED, media); + + if (startWhenPrepared) { + resumeSync(); + } + + playerLock.unlock(); + } + + /** + * Resets the media player and moves it into INITIALIZED state. + * <p/> + * This method is executed on an internal executor service. + */ + public void reinit() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + releaseWifiLockIfNecessary(); + if (media != null) { + playMediaObject(media, true, stream, startWhenPrepared.get(), false); + } else if (mediaPlayer != null) { + mediaPlayer.reset(); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Call to reinit was ignored: media and mediaPlayer were null"); + } + playerLock.unlock(); + } + }); + } + + + /** + * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing. + * + * @param t The position to seek to in milliseconds. t < 0 will be interpreted as t = 0 + * <p/> + * This method is executed on the caller's thread. + */ + private void seekToSync(int t) { + if (t < 0) { + t = 0; + } + playerLock.lock(); + + if (playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) { + if (stream) { + // statusBeforeSeeking = playerStatus; + // setPlayerStatus(PlayerStatus.SEEKING, media); + } + mediaPlayer.seekTo(t); + + } else if (playerStatus == PlayerStatus.INITIALIZED) { + media.setPosition(t); + startWhenPrepared.set(true); + prepare(); + } + playerLock.unlock(); + } + + /** + * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing. + * Invalid time values (< 0) will be ignored. + * <p/> + * This method is executed on an internal executor service. + */ + public void seekTo(final int t) { + executor.submit(new Runnable() { + @Override + public void run() { + seekToSync(t); + } + }); + } + + /** + * Seek a specific position from the current position + * + * @param d offset from current position (positive or negative) + */ + public void seekDelta(final int d) { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + int currentPosition = getPosition(); + if (currentPosition != INVALID_TIME) { + seekToSync(currentPosition + d); + } else { + Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta"); + } + + playerLock.unlock(); + } + }); + } + + /** + * Seek to the start of the specified chapter. + */ + public void seekToChapter(Chapter c) { + Validate.notNull(c); + + seekTo((int) c.getStart()); + } + + /** + * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved. + */ + public int getDuration() { + if (!playerLock.tryLock()) { + return INVALID_TIME; + } + + int retVal = INVALID_TIME; + if (playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) { + retVal = mediaPlayer.getDuration(); + } else if (media != null && media.getDuration() > 0) { + retVal = media.getDuration(); + } + + playerLock.unlock(); + return retVal; + } + + /** + * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved. + */ + public int getPosition() { + if (!playerLock.tryLock()) { + return INVALID_TIME; + } + + int retVal = INVALID_TIME; + if (playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) { + retVal = mediaPlayer.getCurrentPosition(); + } else if (media != null && media.getPosition() > 0) { + retVal = media.getPosition(); + } + + playerLock.unlock(); + return retVal; + } + + public boolean isStartWhenPrepared() { + return startWhenPrepared.get(); + } + + public void setStartWhenPrepared(boolean startWhenPrepared) { + this.startWhenPrepared.set(startWhenPrepared); + } + + /** + * Returns true if the playback speed can be adjusted. + */ + public boolean canSetSpeed() { + boolean retVal = false; + if (mediaPlayer != null && media != null && media.getMediaType() == MediaType.AUDIO) { + retVal = (mediaPlayer).canSetSpeed(); + } + return retVal; + } + + /** + * Sets the playback speed. + * This method is executed on the caller's thread. + */ + private void setSpeedSync(float speed) { + playerLock.lock(); + if (media != null && media.getMediaType() == MediaType.AUDIO) { + if (mediaPlayer.canSetSpeed()) { + mediaPlayer.setPlaybackSpeed((float) speed); + if (BuildConfig.DEBUG) + Log.d(TAG, "Playback speed was set to " + speed); + callback.playbackSpeedChanged(speed); + } + } + playerLock.unlock(); + } + + /** + * Sets the playback speed. + * This method is executed on an internal executor service. + */ + public void setSpeed(final float speed) { + executor.submit(new Runnable() { + @Override + public void run() { + setSpeedSync(speed); + } + }); + } + + /** + * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned. + */ + public float getPlaybackSpeed() { + if (!playerLock.tryLock()) { + return 1; + } + + float retVal = 1; + if ((playerStatus == PlayerStatus.PLAYING + || playerStatus == PlayerStatus.PAUSED + || playerStatus == PlayerStatus.PREPARED) && mediaPlayer.canSetSpeed()) { + retVal = mediaPlayer.getCurrentSpeedMultiplier(); + } + playerLock.unlock(); + return retVal; + } + + public MediaType getCurrentMediaType() { + return mediaType; + } + + public boolean isStreaming() { + return stream; + } + + + /** + * Releases internally used resources. This method should only be called when the object is not used anymore. + */ + public void shutdown() { + executor.shutdown(); + if (mediaPlayer != null) { + mediaPlayer.release(); + } + releaseWifiLockIfNecessary(); + } + + public void setVideoSurface(final SurfaceHolder surface) { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + if (mediaPlayer != null) { + mediaPlayer.setDisplay(surface); + } + playerLock.unlock(); + } + }); + } + + public void resetVideoSurface() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Resetting video surface"); + mediaPlayer.setDisplay(null); + reinit(); + playerLock.unlock(); + } + }); + } + + /** + * Return width and height of the currently playing video as a pair. + * + * @return Width and height as a Pair or null if the video size could not be determined. The method might still + * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return + * invalid values. + */ + public Pair<Integer, Integer> getVideoSize() { + if (!playerLock.tryLock()) { + // use cached value if lock can't be aquired + return videoSize; + } + Pair<Integer, Integer> res; + if (mediaPlayer == null || playerStatus == PlayerStatus.ERROR || mediaType != MediaType.VIDEO) { + res = null; + } else { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + videoSize = new Pair<Integer, Integer>(vp.getVideoWidth(), vp.getVideoHeight()); + res = videoSize; + } + playerLock.unlock(); + return res; + } + + /** + * Returns a PSMInfo object that contains information about the current state of the PSMP object. + * + * @return The PSMPInfo object. + */ + public synchronized PSMPInfo getPSMPInfo() { + return new PSMPInfo(playerStatus, media); + } + + /** + * Sets the player status of the PSMP object. PlayerStatus and media attributes have to be set at the same time + * so that getPSMPInfo can't return an invalid state (e.g. status is PLAYING, but media is null). + * <p/> + * This method will notify the callback about the change of the player status (even if the new status is the same + * as the old one). + * + * @param newStatus The new PlayerStatus. This must not be null. + * @param newMedia The new playable object of the PSMP object. This can be null. + */ + private synchronized void setPlayerStatus(PlayerStatus newStatus, Playable newMedia) { + Validate.notNull(newStatus); + + if (BuildConfig.DEBUG) Log.d(TAG, "Setting player status to " + newStatus); + + this.playerStatus = newStatus; + this.media = newMedia; + callback.statusChanged(new PSMPInfo(playerStatus, media)); + } + + private IPlayer createMediaPlayer() { + if (mediaPlayer != null) { + mediaPlayer.release(); + } + if (media == null || media.getMediaType() == MediaType.VIDEO) { + mediaPlayer = new VideoPlayer(); + } else { + mediaPlayer = new AudioPlayer(context); + } + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK); + return setMediaPlayerListeners(mediaPlayer); + } + + private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() { + + @Override + public void onAudioFocusChange(final int focusChange) { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + + // If there is an incoming call, playback should be paused permanently + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + final int callState = (tm != null) ? tm.getCallState() : 0; + if (BuildConfig.DEBUG) Log.d(TAG, "Call state: " + callState); + Log.i(TAG, "Call state:" + callState); + + if (focusChange == AudioManager.AUDIOFOCUS_LOSS || callState != TelephonyManager.CALL_STATE_IDLE) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Lost audio focus"); + pause(true, false); + callback.shouldStop(); + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Gained audio focus"); + if (pausedBecauseOfTransientAudiofocusLoss) { // we paused => play now + resume(); + } else { // we ducked => raise audio level back + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_RAISE, 0); + } + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + if (playerStatus == PlayerStatus.PLAYING) { + if (!UserPreferences.shouldPauseForFocusLoss()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Lost audio focus temporarily. Ducking..."); + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_LOWER, 0); + pausedBecauseOfTransientAudiofocusLoss = false; + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Lost audio focus temporarily. Could duck, but won't, pausing..."); + pause(false, false); + pausedBecauseOfTransientAudiofocusLoss = true; + } + } + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + if (playerStatus == PlayerStatus.PLAYING) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Lost audio focus temporarily. Pausing..."); + pause(false, false); + pausedBecauseOfTransientAudiofocusLoss = true; + } + playerLock.unlock(); + } + } + }); + } + }; + + + public void endPlayback() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + releaseWifiLockIfNecessary(); + + if (playerStatus != PlayerStatus.INDETERMINATE) { + setPlayerStatus(PlayerStatus.INDETERMINATE, media); + } + if (mediaPlayer != null) { + mediaPlayer.reset(); + + } + audioManager.abandonAudioFocus(audioFocusChangeListener); + callback.endPlayback(true); + + playerLock.unlock(); + } + }); + } + + /** + * Moves the PlaybackServiceMediaPlayer into STOPPED state. This call is only valid if the player is currently in + * INDETERMINATE state, for example after a call to endPlayback. + * This method will only take care of changing the PlayerStatus of this object! Other tasks like + * abandoning audio focus have to be done with other methods. + */ + public void stop() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + releaseWifiLockIfNecessary(); + + if (playerStatus == PlayerStatus.INDETERMINATE) { + setPlayerStatus(PlayerStatus.STOPPED, null); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); + } + playerLock.unlock(); + + } + }); + } + + private synchronized void acquireWifiLockIfNecessary() { + if (stream) { + if (wifiLock == null) { + wifiLock = ((WifiManager) context.getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); + wifiLock.setReferenceCounted(false); + } + wifiLock.acquire(); + } + } + + private synchronized void releaseWifiLockIfNecessary() { + if (wifiLock != null && wifiLock.isHeld()) { + wifiLock.release(); + } + } + + /** + * Holds information about a PSMP object. + */ + public class PSMPInfo { + public PlayerStatus playerStatus; + public Playable playable; + + public PSMPInfo(PlayerStatus playerStatus, Playable playable) { + this.playerStatus = playerStatus; + this.playable = playable; + } + } + + public static interface PSMPCallback { + public void statusChanged(PSMPInfo newInfo); + + public void shouldStop(); + + public void playbackSpeedChanged(float s); + + public void onBufferingUpdate(int percent); + + public boolean onMediaPlayerInfo(int code); + + public boolean onMediaPlayerError(Object inObj, int what, int extra); + + public boolean endPlayback(boolean playNextEpisode); + + public RemoteControlClient getRemoteControlClient(); + } + + private IPlayer setMediaPlayerListeners(IPlayer mp) { + if (mp != null && media != null) { + if (media.getMediaType() == MediaType.AUDIO) { + ((AudioPlayer) mp) + .setOnCompletionListener(audioCompletionListener); + ((AudioPlayer) mp) + .setOnSeekCompleteListener(audioSeekCompleteListener); + ((AudioPlayer) mp).setOnErrorListener(audioErrorListener); + ((AudioPlayer) mp) + .setOnBufferingUpdateListener(audioBufferingUpdateListener); + ((AudioPlayer) mp).setOnInfoListener(audioInfoListener); + } else { + ((VideoPlayer) mp) + .setOnCompletionListener(videoCompletionListener); + ((VideoPlayer) mp) + .setOnSeekCompleteListener(videoSeekCompleteListener); + ((VideoPlayer) mp).setOnErrorListener(videoErrorListener); + ((VideoPlayer) mp) + .setOnBufferingUpdateListener(videoBufferingUpdateListener); + ((VideoPlayer) mp).setOnInfoListener(videoInfoListener); + } + } + return mp; + } + + private final com.aocate.media.MediaPlayer.OnCompletionListener audioCompletionListener = new com.aocate.media.MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(com.aocate.media.MediaPlayer mp) { + genericOnCompletion(); + } + }; + + private final android.media.MediaPlayer.OnCompletionListener videoCompletionListener = new android.media.MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(android.media.MediaPlayer mp) { + genericOnCompletion(); + } + }; + + private void genericOnCompletion() { + endPlayback(); + } + + private final com.aocate.media.MediaPlayer.OnBufferingUpdateListener audioBufferingUpdateListener = new com.aocate.media.MediaPlayer.OnBufferingUpdateListener() { + @Override + public void onBufferingUpdate(com.aocate.media.MediaPlayer mp, + int percent) { + genericOnBufferingUpdate(percent); + } + }; + + private final android.media.MediaPlayer.OnBufferingUpdateListener videoBufferingUpdateListener = new android.media.MediaPlayer.OnBufferingUpdateListener() { + @Override + public void onBufferingUpdate(android.media.MediaPlayer mp, int percent) { + genericOnBufferingUpdate(percent); + } + }; + + private void genericOnBufferingUpdate(int percent) { + callback.onBufferingUpdate(percent); + } + + private final com.aocate.media.MediaPlayer.OnInfoListener audioInfoListener = new com.aocate.media.MediaPlayer.OnInfoListener() { + @Override + public boolean onInfo(com.aocate.media.MediaPlayer mp, int what, + int extra) { + return genericInfoListener(what); + } + }; + + private final android.media.MediaPlayer.OnInfoListener videoInfoListener = new android.media.MediaPlayer.OnInfoListener() { + @Override + public boolean onInfo(android.media.MediaPlayer mp, int what, int extra) { + return genericInfoListener(what); + } + }; + + private boolean genericInfoListener(int what) { + return callback.onMediaPlayerInfo(what); + } + + private final com.aocate.media.MediaPlayer.OnErrorListener audioErrorListener = new com.aocate.media.MediaPlayer.OnErrorListener() { + @Override + public boolean onError(com.aocate.media.MediaPlayer mp, int what, + int extra) { + return genericOnError(mp, what, extra); + } + }; + + private final android.media.MediaPlayer.OnErrorListener videoErrorListener = new android.media.MediaPlayer.OnErrorListener() { + @Override + public boolean onError(android.media.MediaPlayer mp, int what, int extra) { + return genericOnError(mp, what, extra); + } + }; + + private boolean genericOnError(Object inObj, int what, int extra) { + return callback.onMediaPlayerError(inObj, what, extra); + } + + private final com.aocate.media.MediaPlayer.OnSeekCompleteListener audioSeekCompleteListener = new com.aocate.media.MediaPlayer.OnSeekCompleteListener() { + @Override + public void onSeekComplete(com.aocate.media.MediaPlayer mp) { + genericSeekCompleteListener(); + } + }; + + private final android.media.MediaPlayer.OnSeekCompleteListener videoSeekCompleteListener = new android.media.MediaPlayer.OnSeekCompleteListener() { + @Override + public void onSeekComplete(android.media.MediaPlayer mp) { + genericSeekCompleteListener(); + } + }; + + private final void genericSeekCompleteListener() { + executor.submit(new Runnable() { + @Override + public void run() { + playerLock.lock(); + if (playerStatus == PlayerStatus.SEEKING) { + setPlayerStatus(statusBeforeSeeking, media); + } + playerLock.unlock(); + } + }); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java new file mode 100644 index 000000000..680ec2e2f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java @@ -0,0 +1,384 @@ +package de.danoeh.antennapod.service.playback; + +import android.content.Context; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.EventDistributor; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.playback.Playable; + +import java.util.List; +import java.util.concurrent.*; + +/** + * Manages the background tasks of PlaybackSerivce, i.e. + * the sleep timer, the position saver, the widget updater and + * the queue loader. + * <p/> + * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback) + * to notify the PlaybackService about updates from the running tasks. + */ +public class PlaybackServiceTaskManager { + private static final String TAG = "PlaybackServiceTaskManager"; + + /** + * Update interval of position saver in milliseconds. + */ + public static final int POSITION_SAVER_WAITING_INTERVAL = 5000; + /** + * Notification interval of widget updater in milliseconds. + */ + public static final int WIDGET_UPDATER_NOTIFICATION_INTERVAL = 1500; + + private static final int SCHED_EX_POOL_SIZE = 2; + private final ScheduledThreadPoolExecutor schedExecutor; + + private ScheduledFuture positionSaverFuture; + private ScheduledFuture widgetUpdaterFuture; + private ScheduledFuture sleepTimerFuture; + private volatile Future<List<FeedItem>> queueFuture; + private volatile Future chapterLoaderFuture; + + private SleepTimer sleepTimer; + + private final Context context; + private final PSTMCallback callback; + + /** + * Sets up a new PSTM. This method will also start the queue loader task. + * + * @param context + * @param callback A PSTMCallback object for notifying the user about updates. Must not be null. + */ + public PlaybackServiceTaskManager(Context context, PSTMCallback callback) { + Validate.notNull(context); + Validate.notNull(callback); + + this.context = context; + this.callback = callback; + schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE, new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + }); + loadQueue(); + EventDistributor.getInstance().register(eventDistributorListener); + } + + private final EventDistributor.EventListener eventDistributorListener = new EventDistributor.EventListener() { + @Override + public void update(EventDistributor eventDistributor, Integer arg) { + if ((EventDistributor.QUEUE_UPDATE & arg) != 0) { + cancelQueueLoader(); + loadQueue(); + } + } + }; + + private synchronized boolean isQueueLoaderActive() { + return queueFuture != null && !queueFuture.isDone(); + } + + private synchronized void cancelQueueLoader() { + if (isQueueLoaderActive()) { + queueFuture.cancel(true); + } + } + + private synchronized void loadQueue() { + if (!isQueueLoaderActive()) { + queueFuture = schedExecutor.submit(new Callable<List<FeedItem>>() { + @Override + public List<FeedItem> call() throws Exception { + return DBReader.getQueue(context); + } + }); + } + } + + /** + * Returns the queue if it is already loaded or null if it hasn't been loaded yet. + * In order to wait until the queue has been loaded, use getQueue() + */ + public synchronized List<FeedItem> getQueueIfLoaded() { + if (queueFuture.isDone()) { + try { + return queueFuture.get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + return null; + } + + /** + * Returns the queue or waits until the PSTM has loaded the queue from the database. + */ + public synchronized List<FeedItem> getQueue() throws InterruptedException { + try { + return queueFuture.get(); + } catch (ExecutionException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Starts the position saver task. If the position saver is already active, nothing will happen. + */ + public synchronized void startPositionSaver() { + if (!isPositionSaverActive()) { + Runnable positionSaver = new Runnable() { + @Override + public void run() { + callback.positionSaverTick(); + } + }; + positionSaverFuture = schedExecutor.scheduleWithFixedDelay(positionSaver, POSITION_SAVER_WAITING_INTERVAL, + POSITION_SAVER_WAITING_INTERVAL, TimeUnit.MILLISECONDS); + + if (BuildConfig.DEBUG) Log.d(TAG, "Started PositionSaver"); + } else { + if (BuildConfig.DEBUG) Log.d(TAG, "Call to startPositionSaver was ignored."); + } + } + + /** + * Returns true if the position saver is currently running. + */ + public synchronized boolean isPositionSaverActive() { + return positionSaverFuture != null && !positionSaverFuture.isCancelled() && !positionSaverFuture.isDone(); + } + + /** + * Cancels the position saver. If the position saver is not running, nothing will happen. + */ + public synchronized void cancelPositionSaver() { + if (isPositionSaverActive()) { + positionSaverFuture.cancel(false); + if (BuildConfig.DEBUG) Log.d(TAG, "Cancelled PositionSaver"); + } + } + + /** + * Starts the widget updater task. If the widget updater is already active, nothing will happen. + */ + public synchronized void startWidgetUpdater() { + if (!isWidgetUpdaterActive()) { + Runnable widgetUpdater = new Runnable() { + @Override + public void run() { + callback.onWidgetUpdaterTick(); + } + }; + widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL, + WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS); + + if (BuildConfig.DEBUG) Log.d(TAG, "Started WidgetUpdater"); + } else { + if (BuildConfig.DEBUG) Log.d(TAG, "Call to startWidgetUpdater was ignored."); + } + } + + /** + * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be + * cancelled first. + * After waitingTime has elapsed, onSleepTimerExpired() will be called. + * + * @throws java.lang.IllegalArgumentException if waitingTime <= 0 + */ + public synchronized void setSleepTimer(long waitingTime) { + Validate.isTrue(waitingTime > 0, "Waiting time <= 0"); + + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting sleep timer to " + Long.toString(waitingTime) + + " milliseconds"); + if (isSleepTimerActive()) { + sleepTimerFuture.cancel(true); + } + sleepTimer = new SleepTimer(waitingTime); + sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS); + } + + /** + * Returns true if the sleep timer is currently active. + */ + public synchronized boolean isSleepTimerActive() { + return sleepTimer != null && sleepTimerFuture != null && !sleepTimerFuture.isCancelled() && !sleepTimerFuture.isDone() && sleepTimer.isWaiting; + } + + /** + * Disables the sleep timer. If the sleep timer is not active, nothing will happen. + */ + public synchronized void disableSleepTimer() { + if (isSleepTimerActive()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Disabling sleep timer"); + sleepTimerFuture.cancel(true); + } + } + + /** + * Returns the current sleep timer time or 0 if the sleep timer is not active. + */ + public synchronized long getSleepTimerTimeLeft() { + if (isSleepTimerActive()) { + return sleepTimer.getWaitingTime(); + } else { + return 0; + } + } + + + /** + * Returns true if the widget updater is currently running. + */ + public synchronized boolean isWidgetUpdaterActive() { + return widgetUpdaterFuture != null && !widgetUpdaterFuture.isCancelled() && !widgetUpdaterFuture.isDone(); + } + + /** + * Cancels the widget updater. If the widget updater is not running, nothing will happen. + */ + public synchronized void cancelWidgetUpdater() { + if (isWidgetUpdaterActive()) { + widgetUpdaterFuture.cancel(false); + if (BuildConfig.DEBUG) Log.d(TAG, "Cancelled WidgetUpdater"); + } + } + + private synchronized void cancelChapterLoader() { + if (isChapterLoaderActive()) { + chapterLoaderFuture.cancel(true); + } + } + + private synchronized boolean isChapterLoaderActive() { + return chapterLoaderFuture != null && !chapterLoaderFuture.isDone(); + } + + /** + * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active, + * it will be cancelled first. + * On completion, the callback's onChapterLoaded method will be called. + */ + public synchronized void startChapterLoader(final Playable media) { + Validate.notNull(media); + + if (isChapterLoaderActive()) { + cancelChapterLoader(); + } + + Runnable chapterLoader = new Runnable() { + @Override + public void run() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Chapter loader started"); + if (media.getChapters() == null) { + media.loadChapterMarks(); + if (!Thread.currentThread().isInterrupted() && media.getChapters() != null) { + callback.onChapterLoaded(media); + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Chapter loader stopped"); + } + }; + chapterLoaderFuture = schedExecutor.submit(chapterLoader); + } + + + /** + * Cancels all tasks. The PSTM will be in the initial state after execution of this method. + */ + public synchronized void cancelAllTasks() { + cancelPositionSaver(); + cancelWidgetUpdater(); + disableSleepTimer(); + cancelQueueLoader(); + cancelChapterLoader(); + } + + /** + * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after + * execution of this method. + */ + public synchronized void shutdown() { + EventDistributor.getInstance().unregister(eventDistributorListener); + cancelAllTasks(); + schedExecutor.shutdown(); + } + + /** + * Sleeps for a given time and then pauses playback. + */ + private class SleepTimer implements Runnable { + private static final String TAG = "SleepTimer"; + private static final long UPDATE_INTERVALL = 1000L; + private volatile long waitingTime; + private volatile boolean isWaiting; + + public SleepTimer(long waitingTime) { + super(); + this.waitingTime = waitingTime; + isWaiting = true; + } + + @Override + public void run() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting"); + while (waitingTime > 0) { + try { + Thread.sleep(UPDATE_INTERVALL); + waitingTime -= UPDATE_INTERVALL; + + if (waitingTime <= 0) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Waiting completed"); + postExecute(); + if (!Thread.currentThread().isInterrupted()) { + callback.onSleepTimerExpired(); + } + + } + } catch (InterruptedException e) { + Log.d(TAG, "Thread was interrupted while waiting"); + break; + } + } + postExecute(); + } + + protected void postExecute() { + isWaiting = false; + } + + public long getWaitingTime() { + return waitingTime; + } + + public boolean isWaiting() { + return isWaiting; + } + + } + + public static interface PSTMCallback { + void positionSaverTick(); + + void onSleepTimerExpired(); + + void onWidgetUpdaterTick(); + + void onChapterLoaded(Playable media); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java new file mode 100644 index 000000000..3d2b4ad39 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java @@ -0,0 +1,14 @@ +package de.danoeh.antennapod.service.playback; + +public enum PlayerStatus { + INDETERMINATE, // player is currently changing its state, listeners should wait until the player has left this state. + ERROR, + PREPARING, + PAUSED, + PLAYING, + STOPPED, + PREPARED, + SEEKING, + INITIALIZING, // playback service is loading the Playable's metadata + INITIALIZED // playback service was started, data source of media player was set. +} diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java new file mode 100644 index 000000000..71bc40c2a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java @@ -0,0 +1,190 @@ +package de.danoeh.antennapod.service.playback; + +import android.app.PendingIntent; +import android.app.Service; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.widget.RemoteViews; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.receiver.PlayerWidget; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.playback.Playable; + +/** Updates the state of the player widget */ +public class PlayerWidgetService extends Service { + private static final String TAG = "PlayerWidgetService"; + + private PlaybackService playbackService; + /** True while service is updating the widget */ + private volatile boolean isUpdating; + + public PlayerWidgetService() { + } + + @Override + public void onCreate() { + super.onCreate(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Service created"); + isUpdating = false; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Service is about to be destroyed"); + try { + unbindService(mConnection); + } catch (IllegalArgumentException e) { + Log.w(TAG, "IllegalArgumentException when trying to unbind service"); + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (!isUpdating) { + if (playbackService == null && PlaybackService.isRunning) { + bindService(new Intent(this, PlaybackService.class), + mConnection, 0); + } else { + startViewUpdaterIfNotRunning(); + } + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Service was called while updating. Ignoring update request"); + } + return Service.START_NOT_STICKY; + } + + private void updateViews() { + if (playbackService == null) { + return; + } + isUpdating = true; + + ComponentName playerWidget = new ComponentName(this, PlayerWidget.class); + AppWidgetManager manager = AppWidgetManager.getInstance(this); + RemoteViews views = new RemoteViews(getPackageName(), + R.layout.player_widget); + PendingIntent startMediaplayer = PendingIntent.getActivity(this, 0, + PlaybackService.getPlayerActivityIntent(this), 0); + + views.setOnClickPendingIntent(R.id.layout_left, startMediaplayer); + final Playable media = playbackService.getPlayable(); + if (playbackService != null && media != null) { + PlayerStatus status = playbackService.getStatus(); + + views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle()); + + if (status == PlayerStatus.PLAYING) { + String progressString = getProgressString(playbackService); + if (progressString != null) { + views.setTextViewText(R.id.txtvProgress, progressString); + } + views.setImageViewResource(R.id.butPlay, R.drawable.av_pause_dark); + if (Build.VERSION.SDK_INT >= 15) { + views.setContentDescription(R.id.butPlay, getString(R.string.pause_label)); + } + } else { + views.setImageViewResource(R.id.butPlay, R.drawable.av_play_dark); + if (Build.VERSION.SDK_INT >= 15) { + views.setContentDescription(R.id.butPlay, getString(R.string.play_label)); + } + } + views.setOnClickPendingIntent(R.id.butPlay, + createMediaButtonIntent()); + } else { + views.setViewVisibility(R.id.txtvProgress, View.INVISIBLE); + views.setTextViewText(R.id.txtvTitle, + this.getString(R.string.no_media_playing_label)); + views.setImageViewResource(R.id.butPlay, R.drawable.av_play); + + } + + manager.updateAppWidget(playerWidget, views); + isUpdating = false; + } + + /** Creates an intent which fakes a mediabutton press */ + private PendingIntent createMediaButtonIntent() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); + Intent startingIntent = new Intent( + MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER); + startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); + + return PendingIntent.getBroadcast(this, 0, startingIntent, 0); + } + + private String getProgressString(PlaybackService ps) { + int position = ps.getCurrentPosition(); + int duration = ps.getDuration(); + if (position != PlaybackService.INVALID_TIME + && duration != PlaybackService.INVALID_TIME) { + return Converter.getDurationStringLong(position) + " / " + + Converter.getDurationStringLong(duration); + } else { + return null; + } + } + + private ServiceConnection mConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Connection to service established"); + playbackService = ((PlaybackService.LocalBinder) service) + .getService(); + startViewUpdaterIfNotRunning(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + playbackService = null; + if (BuildConfig.DEBUG) + Log.d(TAG, "Disconnected from service"); + } + + }; + + private void startViewUpdaterIfNotRunning() { + if (!isUpdating) { + ViewUpdater updateThread = new ViewUpdater(this); + updateThread.start(); + } + } + + static class ViewUpdater extends Thread { + private static final String THREAD_NAME = "ViewUpdater"; + private PlayerWidgetService service; + + public ViewUpdater(PlayerWidgetService service) { + super(); + setName(THREAD_NAME); + this.service = service; + + } + + @Override + public void run() { + service.updateViews(); + } + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java b/app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java new file mode 100644 index 000000000..75cbd8b5a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.spa; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.receiver.SPAReceiver; + +/** + * Provides methods related to AntennaPodSP (https://github.com/danieloeh/AntennaPodSP) + */ +public class SPAUtil { + private static final String TAG = "SPAUtil"; + + private static final String PREF_HAS_QUERIED_SP_APPS = "prefSPAUtil.hasQueriedSPApps"; + + private SPAUtil() { + } + + + /** + * Sends an ACTION_SP_APPS_QUERY_FEEDS intent to all AntennaPod Single Purpose apps. + * The receiving single purpose apps will then send their feeds back to AntennaPod via an + * ACTION_SP_APPS_QUERY_FEEDS_RESPONSE intent. + * This intent will only be sent once. + * + * @return True if an intent was sent, false otherwise (for example if the intent has already been + * sent before. + */ + public static synchronized boolean sendSPAppsQueryFeedsIntent(Context context) { + if (context == null) throw new IllegalArgumentException("context = null"); + final Context appContext = context.getApplicationContext(); + if (appContext == null) { + Log.wtf(TAG, "Unable to get application context"); + return false; + } + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(appContext); + if (!prefs.getBoolean(PREF_HAS_QUERIED_SP_APPS, false)) { + appContext.sendBroadcast(new Intent(SPAReceiver.ACTION_SP_APPS_QUERY_FEEDS)); + if (BuildConfig.DEBUG) Log.d(TAG, "Sending SP_APPS_QUERY_FEEDS intent"); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(PREF_HAS_QUERIED_SP_APPS, true); + editor.commit(); + + return true; + } else { + return false; + } + } + + /** + * Resets all preferences created by this class. Should only be used for debug purposes. + */ + public static void resetSPAPreferences(Context c) { + if (BuildConfig.DEBUG) { + Validate.notNull(c); + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(c.getApplicationContext()).edit(); + editor.putBoolean(PREF_HAS_QUERIED_SP_APPS, false); + editor.commit(); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DBReader.java b/app/src/main/java/de/danoeh/antennapod/storage/DBReader.java new file mode 100644 index 000000000..e49ea4f83 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/DBReader.java @@ -0,0 +1,908 @@ +package de.danoeh.antennapod.storage; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.*; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.util.DownloadError; +import de.danoeh.antennapod.util.comparator.DownloadStatusComparator; +import de.danoeh.antennapod.util.comparator.FeedItemPubdateComparator; +import de.danoeh.antennapod.util.comparator.PlaybackCompletionDateComparator; +import de.danoeh.antennapod.util.flattr.FlattrStatus; +import de.danoeh.antennapod.util.flattr.FlattrThing; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * Provides methods for reading data from the AntennaPod database. + * In general, all database calls in DBReader-methods are executed on the caller's thread. + * This means that the caller should make sure that DBReader-methods are not executed on the GUI-thread. + * This class will use the {@link de.danoeh.antennapod.feed.EventDistributor} to notify listeners about changes in the database. + */ +public final class DBReader { + private static final String TAG = "DBReader"; + + /** + * Maximum size of the list returned by {@link #getPlaybackHistory(android.content.Context)}. + */ + public static final int PLAYBACK_HISTORY_SIZE = 50; + + /** + * Maximum size of the list returned by {@link #getDownloadLog(android.content.Context)}. + */ + public static final int DOWNLOAD_LOG_SIZE = 200; + + + private DBReader() { + } + + /** + * Returns a list of Feeds, sorted alphabetically by their title. + * + * @param context A context that is used for opening a database connection. + * @return A list of Feeds, sorted alphabetically by their title. A Feed-object + * of the returned list does NOT have its list of FeedItems yet. The FeedItem-list + * can be loaded separately with {@link #getFeedItemList(android.content.Context, de.danoeh.antennapod.feed.Feed)}. + */ + public static List<Feed> getFeedList(final Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting Feedlist"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List<Feed> result = getFeedList(adapter); + adapter.close(); + return result; + } + + private static List<Feed> getFeedList(PodDBAdapter adapter) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting Feedlist"); + + Cursor feedlistCursor = adapter.getAllFeedsCursor(); + List<Feed> feeds = new ArrayList<Feed>(feedlistCursor.getCount()); + + if (feedlistCursor.moveToFirst()) { + do { + Feed feed = extractFeedFromCursorRow(adapter, feedlistCursor); + feeds.add(feed); + } while (feedlistCursor.moveToNext()); + } + feedlistCursor.close(); + return feeds; + } + + /** + * Returns a list with the download URLs of all feeds. + * + * @param context A context that is used for opening the database connection. + * @return A list of Strings with the download URLs of all feeds. + */ + public static List<String> getFeedListDownloadUrls(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + List<String> result = new ArrayList<String>(); + adapter.open(); + Cursor feeds = adapter.getFeedCursorDownloadUrls(); + if (feeds.moveToFirst()) { + do { + result.add(feeds.getString(1)); + } while (feeds.moveToNext()); + } + feeds.close(); + adapter.close(); + + return result; + } + + /** + * Returns a list of 'expired Feeds', i.e. Feeds that have not been updated for a certain amount of time. + * + * @param context A context that is used for opening a database connection. + * @param expirationTime Time that is used for determining whether a feed is outdated or not. + * A Feed is considered expired if 'lastUpdate < (currentTime - expirationTime)' evaluates to true. + * @return A list of Feeds, sorted alphabetically by their title. A Feed-object + * of the returned list does NOT have its list of FeedItems yet. The FeedItem-list + * can be loaded separately with {@link #getFeedItemList(android.content.Context, de.danoeh.antennapod.feed.Feed)}. + */ + public static List<Feed> getExpiredFeedsList(final Context context, final long expirationTime) { + if (BuildConfig.DEBUG) + Log.d(TAG, String.format("getExpiredFeedsList(%d)", expirationTime)); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor feedlistCursor = adapter.getExpiredFeedsCursor(expirationTime); + List<Feed> feeds = new ArrayList<Feed>(feedlistCursor.getCount()); + + if (feedlistCursor.moveToFirst()) { + do { + Feed feed = extractFeedFromCursorRow(adapter, feedlistCursor); + feeds.add(feed); + } while (feedlistCursor.moveToNext()); + } + feedlistCursor.close(); + return feeds; + } + + /** + * Takes a list of FeedItems and loads their corresponding Feed-objects from the database. + * The feedID-attribute of a FeedItem must be set to the ID of its feed or the method will + * not find the correct feed of an item. + * + * @param context A context that is used for opening a database connection. + * @param items The FeedItems whose Feed-objects should be loaded. + */ + public static void loadFeedDataOfFeedItemlist(Context context, + List<FeedItem> items) { + List<Feed> feeds = getFeedList(context); + for (FeedItem item : items) { + for (Feed feed : feeds) { + if (feed.getId() == item.getFeedId()) { + item.setFeed(feed); + break; + } + } + if (item.getFeed() == null) { + Log.w(TAG, "No match found for item with ID " + item.getId() + ". Feed ID was " + item.getFeedId()); + } + } + } + + /** + * Loads the list of FeedItems for a certain Feed-object. This method should NOT be used if the FeedItems are not + * used. In order to get information ABOUT the list of FeedItems, consider using {@link #getFeedStatisticsList(android.content.Context)} instead. + * + * @param context A context that is used for opening a database connection. + * @param feed The Feed whose items should be loaded + * @return A list with the FeedItems of the Feed. The Feed-attribute of the FeedItems will already be set correctly. + * The method does NOT change the items-attribute of the feed. + */ + public static List<FeedItem> getFeedItemList(Context context, + final Feed feed) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting Feeditems of feed " + feed.getTitle()); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor itemlistCursor = adapter.getAllItemsOfFeedCursor(feed); + List<FeedItem> items = extractItemlistFromCursor(adapter, + itemlistCursor); + itemlistCursor.close(); + + Collections.sort(items, new FeedItemPubdateComparator()); + + adapter.close(); + + for (FeedItem item : items) { + item.setFeed(feed); + } + + return items; + } + + static List<FeedItem> extractItemlistFromCursor(Context context, Cursor itemlistCursor) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List<FeedItem> result = extractItemlistFromCursor(adapter, itemlistCursor); + adapter.close(); + return result; + } + + private static List<FeedItem> extractItemlistFromCursor( + PodDBAdapter adapter, Cursor itemlistCursor) { + ArrayList<String> itemIds = new ArrayList<String>(); + List<FeedItem> items = new ArrayList<FeedItem>( + itemlistCursor.getCount()); + + if (itemlistCursor.moveToFirst()) { + do { + FeedItem item = new FeedItem(); + + item.setId(itemlistCursor.getLong(PodDBAdapter.IDX_FI_SMALL_ID)); + item.setTitle(itemlistCursor + .getString(PodDBAdapter.IDX_FI_SMALL_TITLE)); + item.setLink(itemlistCursor + .getString(PodDBAdapter.IDX_FI_SMALL_LINK)); + item.setPubDate(new Date(itemlistCursor + .getLong(PodDBAdapter.IDX_FI_SMALL_PUBDATE))); + item.setPaymentLink(itemlistCursor + .getString(PodDBAdapter.IDX_FI_SMALL_PAYMENT_LINK)); + item.setFeedId(itemlistCursor + .getLong(PodDBAdapter.IDX_FI_SMALL_FEED)); + itemIds.add(String.valueOf(item.getId())); + + item.setRead((itemlistCursor + .getInt(PodDBAdapter.IDX_FI_SMALL_READ) > 0)); + item.setItemIdentifier(itemlistCursor + .getString(PodDBAdapter.IDX_FI_SMALL_ITEM_IDENTIFIER)); + item.setFlattrStatus(new FlattrStatus(itemlistCursor + .getLong(PodDBAdapter.IDX_FI_SMALL_FLATTR_STATUS))); + + long imageIndex = itemlistCursor.getLong(PodDBAdapter.IDX_FI_SMALL_IMAGE); + if (imageIndex != 0) { + item.setImage(getFeedImage(adapter, imageIndex)); + } + + // extract chapters + boolean hasSimpleChapters = itemlistCursor + .getInt(PodDBAdapter.IDX_FI_SMALL_HAS_CHAPTERS) > 0; + if (hasSimpleChapters) { + Cursor chapterCursor = adapter + .getSimpleChaptersOfFeedItemCursor(item); + if (chapterCursor.moveToFirst()) { + item.setChapters(new ArrayList<Chapter>()); + do { + int chapterType = chapterCursor + .getInt(PodDBAdapter.KEY_CHAPTER_TYPE_INDEX); + Chapter chapter = null; + long start = chapterCursor + .getLong(PodDBAdapter.KEY_CHAPTER_START_INDEX); + String title = chapterCursor + .getString(PodDBAdapter.KEY_TITLE_INDEX); + String link = chapterCursor + .getString(PodDBAdapter.KEY_CHAPTER_LINK_INDEX); + + switch (chapterType) { + case SimpleChapter.CHAPTERTYPE_SIMPLECHAPTER: + chapter = new SimpleChapter(start, title, item, + link); + break; + case ID3Chapter.CHAPTERTYPE_ID3CHAPTER: + chapter = new ID3Chapter(start, title, item, + link); + break; + case VorbisCommentChapter.CHAPTERTYPE_VORBISCOMMENT_CHAPTER: + chapter = new VorbisCommentChapter(start, + title, item, link); + break; + } + if (chapter != null) { + chapter.setId(chapterCursor + .getLong(PodDBAdapter.KEY_ID_INDEX)); + item.getChapters().add(chapter); + } + } while (chapterCursor.moveToNext()); + } + chapterCursor.close(); + } + items.add(item); + } while (itemlistCursor.moveToNext()); + } + + extractMediafromItemlist(adapter, items, itemIds); + return items; + } + + private static void extractMediafromItemlist(PodDBAdapter adapter, + List<FeedItem> items, ArrayList<String> itemIds) { + + List<FeedItem> itemsCopy = new ArrayList<FeedItem>(items); + Cursor cursor = adapter.getFeedMediaCursorByItemID(itemIds + .toArray(new String[itemIds.size()])); + if (cursor.moveToFirst()) { + do { + long itemId = cursor.getLong(PodDBAdapter.KEY_MEDIA_FEEDITEM_INDEX); + // find matching feed item + FeedItem item = getMatchingItemForMedia(itemId, itemsCopy); + if (item != null) { + item.setMedia(extractFeedMediaFromCursorRow(cursor)); + item.getMedia().setItem(item); + } + } while (cursor.moveToNext()); + cursor.close(); + } + } + + private static FeedMedia extractFeedMediaFromCursorRow(final Cursor cursor) { + long mediaId = cursor.getLong(PodDBAdapter.KEY_ID_INDEX); + Date playbackCompletionDate = null; + long playbackCompletionTime = cursor + .getLong(PodDBAdapter.KEY_PLAYBACK_COMPLETION_DATE_INDEX); + if (playbackCompletionTime > 0) { + playbackCompletionDate = new Date( + playbackCompletionTime); + } + + return new FeedMedia( + mediaId, + null, + cursor.getInt(PodDBAdapter.KEY_DURATION_INDEX), + cursor.getInt(PodDBAdapter.KEY_POSITION_INDEX), + cursor.getLong(PodDBAdapter.KEY_SIZE_INDEX), + cursor.getString(PodDBAdapter.KEY_MIME_TYPE_INDEX), + cursor.getString(PodDBAdapter.KEY_FILE_URL_INDEX), + cursor.getString(PodDBAdapter.KEY_DOWNLOAD_URL_INDEX), + cursor.getInt(PodDBAdapter.KEY_DOWNLOADED_INDEX) > 0, + playbackCompletionDate, + cursor.getInt(PodDBAdapter.KEY_PLAYED_DURATION_INDEX)); + } + + private static Feed extractFeedFromCursorRow(PodDBAdapter adapter, + Cursor cursor) { + Date lastUpdate = new Date( + cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_LASTUPDATE)); + + final FeedImage image; + long imageIndex = cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_IMAGE); + if (imageIndex != 0) { + image = getFeedImage(adapter, imageIndex); + } else { + image = null; + } + Feed feed = new Feed(cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_ID), + lastUpdate, + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_TITLE), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_LINK), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_DESCRIPTION), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_PAYMENT_LINK), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_AUTHOR), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_LANGUAGE), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_TYPE), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_FEED_IDENTIFIER), + image, + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_FILE_URL), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_DOWNLOAD_URL), + cursor.getInt(PodDBAdapter.IDX_FEED_SEL_STD_DOWNLOADED) > 0, + new FlattrStatus(cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_FLATTR_STATUS))); + + if (image != null) { + image.setOwner(feed); + } + + FeedPreferences preferences = new FeedPreferences(cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_ID), + cursor.getInt(PodDBAdapter.IDX_FEED_SEL_PREFERENCES_AUTO_DOWNLOAD) > 0, + cursor.getString(PodDBAdapter.IDX_FEED_SEL_PREFERENCES_USERNAME), + cursor.getString(PodDBAdapter.IDX_FEED_SEL_PREFERENCES_PASSWORD)); + + feed.setPreferences(preferences); + return feed; + } + + private static FeedItem getMatchingItemForMedia(long itemId, + List<FeedItem> items) { + for (FeedItem item : items) { + if (item.getId() == itemId) { + return item; + } + } + return null; + } + + static List<FeedItem> getQueue(Context context, PodDBAdapter adapter) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting queue"); + + Cursor itemlistCursor = adapter.getQueueCursor(); + List<FeedItem> items = extractItemlistFromCursor(adapter, + itemlistCursor); + itemlistCursor.close(); + loadFeedDataOfFeedItemlist(context, items); + + return items; + } + + /** + * Loads the IDs of the FeedItems in the queue. This method should be preferred over + * {@link #getQueue(android.content.Context)} if the FeedItems of the queue are not needed. + * + * @param context A context that is used for opening a database connection. + * @return A list of IDs sorted by the same order as the queue. The caller can wrap the returned + * list in a {@link de.danoeh.antennapod.util.QueueAccess} object for easier access to the queue's properties. + */ + public static List<Long> getQueueIDList(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + + adapter.open(); + List<Long> result = getQueueIDList(adapter); + adapter.close(); + + return result; + } + + static List<Long> getQueueIDList(PodDBAdapter adapter) { + adapter.open(); + Cursor queueCursor = adapter.getQueueIDCursor(); + + List<Long> queueIds = new ArrayList<Long>(queueCursor.getCount()); + if (queueCursor.moveToFirst()) { + do { + queueIds.add(queueCursor.getLong(0)); + } while (queueCursor.moveToNext()); + } + return queueIds; + } + + + /** + * Loads a list of the FeedItems in the queue. If the FeedItems of the queue are not used directly, consider using + * {@link #getQueueIDList(android.content.Context)} instead. + * + * @param context A context that is used for opening a database connection. + * @return A list of FeedItems sorted by the same order as the queue. The caller can wrap the returned + * list in a {@link de.danoeh.antennapod.util.QueueAccess} object for easier access to the queue's properties. + */ + public static List<FeedItem> getQueue(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting queue"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List<FeedItem> items = getQueue(context, adapter); + adapter.close(); + return items; + } + + /** + * Loads a list of FeedItems whose episode has been downloaded. + * + * @param context A context that is used for opening a database connection. + * @return A list of FeedItems whose episdoe has been downloaded. + */ + public static List<FeedItem> getDownloadedItems(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting downloaded items"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor itemlistCursor = adapter.getDownloadedItemsCursor(); + List<FeedItem> items = extractItemlistFromCursor(adapter, + itemlistCursor); + itemlistCursor.close(); + loadFeedDataOfFeedItemlist(context, items); + Collections.sort(items, new FeedItemPubdateComparator()); + + adapter.close(); + return items; + + } + + /** + * Loads a list of FeedItems whose 'read'-attribute is set to false. + * + * @param context A context that is used for opening a database connection. + * @return A list of FeedItems whose 'read'-attribute it set to false. If the FeedItems in the list are not used, + * consider using {@link #getUnreadItemIds(android.content.Context)} instead. + */ + public static List<FeedItem> getUnreadItemsList(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting unread items list"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor itemlistCursor = adapter.getUnreadItemsCursor(); + List<FeedItem> items = extractItemlistFromCursor(adapter, + itemlistCursor); + itemlistCursor.close(); + + loadFeedDataOfFeedItemlist(context, items); + + adapter.close(); + + return items; + } + + /** + * Loads the IDs of the FeedItems whose 'read'-attribute is set to false. + * + * @param context A context that is used for opening a database connection. + * @return A list of IDs of the FeedItems whose 'read'-attribute is set to false. This method should be preferred + * over {@link #getUnreadItemsList(android.content.Context)} if the FeedItems in the UnreadItems list are not used. + */ + public static long[] getUnreadItemIds(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor cursor = adapter.getUnreadItemIdsCursor(); + long[] itemIds = new long[cursor.getCount()]; + int i = 0; + if (cursor.moveToFirst()) { + do { + itemIds[i] = cursor.getLong(PodDBAdapter.KEY_ID_INDEX); + i++; + } while (cursor.moveToNext()); + } + return itemIds; + } + + + /** + * Loads a list of FeedItems sorted by pubDate in descending order. + * + * @param context A context that is used for opening a database connection. + * @param limit The maximum number of episodes that should be loaded. + */ + public static List<FeedItem> getRecentlyPublishedEpisodes(Context context, int limit) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting recently published items list"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor itemlistCursor = adapter.getRecentlyPublishedItemsCursor(limit); + List<FeedItem> items = extractItemlistFromCursor(adapter, + itemlistCursor); + itemlistCursor.close(); + + loadFeedDataOfFeedItemlist(context, items); + + adapter.close(); + + return items; + } + + /** + * Loads the playback history from the database. A FeedItem is in the playback history if playback of the correpsonding episode + * has been completed at least once. + * + * @param context A context that is used for opening a database connection. + * @return The playback history. The FeedItems are sorted by their media's playbackCompletionDate in descending order. + * The size of the returned list is limited by {@link #PLAYBACK_HISTORY_SIZE}. + */ + public static List<FeedItem> getPlaybackHistory(final Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading playback history"); + final int PLAYBACK_HISTORY_SIZE = 50; + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor mediaCursor = adapter.getCompletedMediaCursor(PLAYBACK_HISTORY_SIZE); + String[] itemIds = new String[mediaCursor.getCount()]; + for (int i = 0; i < itemIds.length && mediaCursor.moveToPosition(i); i++) { + itemIds[i] = Long.toString(mediaCursor.getLong(PodDBAdapter.KEY_MEDIA_FEEDITEM_INDEX)); + } + mediaCursor.close(); + Cursor itemCursor = adapter.getFeedItemCursor(itemIds); + List<FeedItem> items = extractItemlistFromCursor(adapter, itemCursor); + loadFeedDataOfFeedItemlist(context, items); + itemCursor.close(); + adapter.close(); + + Collections.sort(items, new PlaybackCompletionDateComparator()); + return items; + } + + /** + * Loads the download log from the database. + * + * @param context A context that is used for opening a database connection. + * @return A list with DownloadStatus objects that represent the download log. + * The size of the returned list is limited by {@link #DOWNLOAD_LOG_SIZE}. + */ + public static List<DownloadStatus> getDownloadLog(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting DownloadLog"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor logCursor = adapter.getDownloadLogCursor(DOWNLOAD_LOG_SIZE); + List<DownloadStatus> downloadLog = new ArrayList<DownloadStatus>( + logCursor.getCount()); + + if (logCursor.moveToFirst()) { + do { + long id = logCursor.getLong(PodDBAdapter.KEY_ID_INDEX); + + long feedfileId = logCursor + .getLong(PodDBAdapter.KEY_FEEDFILE_INDEX); + int feedfileType = logCursor + .getInt(PodDBAdapter.KEY_FEEDFILETYPE_INDEX); + boolean successful = logCursor + .getInt(PodDBAdapter.KEY_SUCCESSFUL_INDEX) > 0; + int reason = logCursor.getInt(PodDBAdapter.KEY_REASON_INDEX); + String reasonDetailed = logCursor + .getString(PodDBAdapter.KEY_REASON_DETAILED_INDEX); + String title = logCursor + .getString(PodDBAdapter.KEY_DOWNLOADSTATUS_TITLE_INDEX); + Date completionDate = new Date( + logCursor + .getLong(PodDBAdapter.KEY_COMPLETION_DATE_INDEX) + ); + downloadLog.add(new DownloadStatus(id, title, feedfileId, + feedfileType, successful, DownloadError.fromCode(reason), completionDate, + reasonDetailed)); + + } while (logCursor.moveToNext()); + } + logCursor.close(); + Collections.sort(downloadLog, new DownloadStatusComparator()); + return downloadLog; + } + + /** + * Loads the FeedItemStatistics objects of all Feeds in the database. This method should be preferred over + * {@link #getFeedItemList(android.content.Context, de.danoeh.antennapod.feed.Feed)} if only metadata about + * the FeedItems is needed. + * + * @param context A context that is used for opening a database connection. + * @return A list of FeedItemStatistics objects sorted alphabetically by their Feed's title. + */ + public static List<FeedItemStatistics> getFeedStatisticsList(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List<FeedItemStatistics> result = new ArrayList<FeedItemStatistics>(); + Cursor cursor = adapter.getFeedStatisticsCursor(); + if (cursor.moveToFirst()) { + do { + result.add(new FeedItemStatistics(cursor.getLong(PodDBAdapter.IDX_FEEDSTATISTICS_FEED), + cursor.getInt(PodDBAdapter.IDX_FEEDSTATISTICS_NUM_ITEMS), + cursor.getInt(PodDBAdapter.IDX_FEEDSTATISTICS_NEW_ITEMS), + cursor.getInt(PodDBAdapter.IDX_FEEDSTATISTICS_IN_PROGRESS_EPISODES), + new Date(cursor.getLong(PodDBAdapter.IDX_FEEDSTATISTICS_LATEST_EPISODE)))); + } while (cursor.moveToNext()); + } + + cursor.close(); + adapter.close(); + return result; + } + + /** + * Loads a specific Feed from the database. + * + * @param context A context that is used for opening a database connection. + * @param feedId The ID of the Feed + * @return The Feed or null if the Feed could not be found. The Feeds FeedItems will also be loaded from the + * database and the items-attribute will be set correctly. + */ + public static Feed getFeed(final Context context, final long feedId) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Feed result = getFeed(context, feedId, adapter); + adapter.close(); + return result; + } + + static Feed getFeed(final Context context, final long feedId, PodDBAdapter adapter) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading feed with id " + feedId); + Feed feed = null; + + Cursor feedCursor = adapter.getFeedCursor(feedId); + if (feedCursor.moveToFirst()) { + feed = extractFeedFromCursorRow(adapter, feedCursor); + feed.setItems(getFeedItemList(context, feed)); + } else { + Log.e(TAG, "getFeed could not find feed with id " + feedId); + } + feedCursor.close(); + return feed; + } + + static FeedItem getFeedItem(final Context context, final long itemId, PodDBAdapter adapter) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading feeditem with id " + itemId); + FeedItem item = null; + + Cursor itemCursor = adapter.getFeedItemCursor(Long.toString(itemId)); + if (itemCursor.moveToFirst()) { + List<FeedItem> list = extractItemlistFromCursor(adapter, itemCursor); + if (list.size() > 0) { + item = list.get(0); + loadFeedDataOfFeedItemlist(context, list); + } + } + return item; + + } + + /** + * Loads a specific FeedItem from the database. + * + * @param context A context that is used for opening a database connection. + * @param itemId The ID of the FeedItem + * @return The FeedItem or null if the FeedItem could not be found. All FeedComponent-attributes of the FeedItem will + * also be loaded from the database. + */ + public static FeedItem getFeedItem(final Context context, final long itemId) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Loading feeditem with id " + itemId); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + FeedItem item = getFeedItem(context, itemId, adapter); + adapter.close(); + return item; + + } + + /** + * Loads additional information about a FeedItem, e.g. shownotes + * + * @param context A context that is used for opening a database connection. + * @param item The FeedItem + */ + public static void loadExtraInformationOfFeedItem(final Context context, final FeedItem item) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor extraCursor = adapter.getExtraInformationOfItem(item); + if (extraCursor.moveToFirst()) { + String description = extraCursor + .getString(PodDBAdapter.IDX_FI_EXTRA_DESCRIPTION); + String contentEncoded = extraCursor + .getString(PodDBAdapter.IDX_FI_EXTRA_CONTENT_ENCODED); + item.setDescription(description); + item.setContentEncoded(contentEncoded); + } + adapter.close(); + } + + /** + * Returns the number of downloaded episodes. + * + * @param context A context that is used for opening a database connection. + * @return The number of downloaded episodes. + */ + public static int getNumberOfDownloadedEpisodes(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + final int result = adapter.getNumberOfDownloadedEpisodes(); + adapter.close(); + return result; + } + + /** + * Returns the number of unread items. + * + * @param context A context that is used for opening a database connection. + * @return The number of unread items. + */ + public static int getNumberOfUnreadItems(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + final int result = adapter.getNumberOfUnreadItems(); + adapter.close(); + return result; + } + + /** + * Searches the DB for a FeedImage of the given id. + * + * @param context A context that is used for opening a database connection. + * @param imageId The id of the object + * @return The found object + */ + public static FeedImage getFeedImage(final Context context, final long imageId) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + FeedImage result = getFeedImage(adapter, imageId); + adapter.close(); + return result; + } + + /** + * Searches the DB for a FeedImage of the given id. + * + * @param id The id of the object + * @return The found object + */ + static FeedImage getFeedImage(PodDBAdapter adapter, final long id) { + Cursor cursor = adapter.getImageCursor(id); + if ((cursor.getCount() == 0) || !cursor.moveToFirst()) { + throw new SQLException("No FeedImage found at index: " + id); + } + FeedImage image = new FeedImage(id, cursor.getString(cursor + .getColumnIndex(PodDBAdapter.KEY_TITLE)), + cursor.getString(cursor + .getColumnIndex(PodDBAdapter.KEY_FILE_URL)), + cursor.getString(cursor + .getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL)), + cursor.getInt(cursor + .getColumnIndex(PodDBAdapter.KEY_DOWNLOADED)) > 0 + ); + cursor.close(); + return image; + } + + /** + * Searches the DB for a FeedMedia of the given id. + * + * @param context A context that is used for opening a database connection. + * @param mediaId The id of the object + * @return The found object + */ + public static FeedMedia getFeedMedia(final Context context, final long mediaId) { + PodDBAdapter adapter = new PodDBAdapter(context); + + adapter.open(); + Cursor mediaCursor = adapter.getSingleFeedMediaCursor(mediaId); + + FeedMedia media = null; + if (mediaCursor.moveToFirst()) { + final long itemId = mediaCursor.getLong(PodDBAdapter.KEY_MEDIA_FEEDITEM_INDEX); + media = extractFeedMediaFromCursorRow(mediaCursor); + FeedItem item = getFeedItem(context, itemId); + if (media != null && item != null) { + media.setItem(item); + item.setMedia(media); + } + } + + mediaCursor.close(); + adapter.close(); + + return media; + } + + /** + * Returns the flattr queue as a List of FlattrThings. The list consists of Feeds and FeedItems. + * + * @param context A context that is used for opening a database connection. + * @return The flattr queue as a List. + */ + public static List<FlattrThing> getFlattrQueue(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List<FlattrThing> result = new ArrayList<FlattrThing>(); + + // load feeds + Cursor feedCursor = adapter.getFeedsInFlattrQueueCursor(); + if (feedCursor.moveToFirst()) { + do { + result.add(extractFeedFromCursorRow(adapter, feedCursor)); + } while (feedCursor.moveToNext()); + } + feedCursor.close(); + + //load feed items + Cursor feedItemCursor = adapter.getFeedItemsInFlattrQueueCursor(); + result.addAll(extractItemlistFromCursor(adapter, feedItemCursor)); + feedItemCursor.close(); + + adapter.close(); + Log.d(TAG, "Returning flattrQueueIterator for queue with " + result.size() + " items."); + return result; + } + + + /** + * Returns true if the flattr queue is empty. + * + * @param context A context that is used for opening a database connection. + */ + public static boolean getFlattrQueueEmpty(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + boolean empty = adapter.getFlattrQueueSize() == 0; + adapter.close(); + return empty; + } + + /** + * Returns data necessary for displaying the navigation drawer. This includes + * the list of subscriptions, the number of items in the queue and the number of unread + * items. + * + * @param context A context that is used for opening a database connection. + */ + public static NavDrawerData getNavDrawerData(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List<Feed> feeds = getFeedList(adapter); + int queueSize = adapter.getQueueSize(); + int numUnreadItems = adapter.getNumberOfUnreadItems(); + NavDrawerData result = new NavDrawerData(feeds, queueSize, numUnreadItems); + adapter.close(); + return result; + } + + public static class NavDrawerData { + public List<Feed> feeds; + public int queueSize; + public int numUnreadItems; + + public NavDrawerData(List<Feed> feeds, int queueSize, int numUnreadItems) { + this.feeds = feeds; + this.queueSize = queueSize; + this.numUnreadItems = numUnreadItems; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java b/app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java new file mode 100644 index 000000000..a230ba797 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java @@ -0,0 +1,895 @@ +package de.danoeh.antennapod.storage; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.asynctask.FlattrClickWorker; +import de.danoeh.antennapod.asynctask.FlattrStatusFetcher; +import de.danoeh.antennapod.feed.*; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.GpodnetSyncService; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.util.DownloadError; +import de.danoeh.antennapod.util.NetworkUtils; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.comparator.FeedItemPubdateComparator; +import de.danoeh.antennapod.util.exception.MediaFileNotFoundException; +import de.danoeh.antennapod.util.flattr.FlattrUtils; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Provides methods for doing common tasks that use DBReader and DBWriter. + */ +public final class DBTasks { + private static final String TAG = "DBTasks"; + + /** + * Executor service used by the autodownloadUndownloadedEpisodes method. + */ + private static ExecutorService autodownloadExec; + + static { + autodownloadExec = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + }); + } + + private DBTasks() { + } + + /** + * Removes the feed with the given download url. This method should NOT be executed on the GUI thread. + * + * @param context Used for accessing the db + * @param downloadUrl URL of the feed. + */ + public static void removeFeedWithDownloadUrl(Context context, String downloadUrl) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor cursor = adapter.getFeedCursorDownloadUrls(); + long feedID = 0; + if (cursor.moveToFirst()) { + do { + if (cursor.getString(1).equals(downloadUrl)) { + feedID = cursor.getLong(0); + } + } while (cursor.moveToNext()); + } + cursor.close(); + adapter.close(); + + if (feedID != 0) { + try { + DBWriter.deleteFeed(context, feedID).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } else { + Log.w(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: " + downloadUrl); + } + } + + /** + * Starts playback of a FeedMedia object's file. This method will build an Intent based on the given parameters to + * start the {@link PlaybackService}. + * + * @param context Used for sending starting Services and Activities. + * @param media The FeedMedia object. + * @param showPlayer If true, starts the appropriate player activity ({@link de.danoeh.antennapod.activity.AudioplayerActivity} + * or {@link de.danoeh.antennapod.activity.VideoplayerActivity} + * @param startWhenPrepared Parameter for the {@link PlaybackService} start intent. If true, playback will start as + * soon as the PlaybackService has finished loading the FeedMedia object's file. + * @param shouldStream Parameter for the {@link PlaybackService} start intent. If true, the FeedMedia object's file + * will be streamed, otherwise the downloaded file will be used. If the downloaded file cannot be + * found, the PlaybackService will shutdown and the database entry of the FeedMedia object will be + * corrected. + */ + public static void playMedia(final Context context, final FeedMedia media, + boolean showPlayer, boolean startWhenPrepared, boolean shouldStream) { + try { + if (!shouldStream) { + if (media.fileExists() == false) { + throw new MediaFileNotFoundException( + "No episode was found at " + media.getFile_url(), + media); + } + } + // Start playback Service + Intent launchIntent = new Intent(context, PlaybackService.class); + launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); + launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, + startWhenPrepared); + launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, + shouldStream); + launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, + true); + context.startService(launchIntent); + if (showPlayer) { + // Launch media player + context.startActivity(PlaybackService.getPlayerActivityIntent( + context, media)); + } + DBWriter.addQueueItemAt(context, media.getItem().getId(), 0, false); + } catch (MediaFileNotFoundException e) { + e.printStackTrace(); + if (media.isPlaying()) { + context.sendBroadcast(new Intent( + PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + } + notifyMissingFeedMediaFile(context, media); + } + } + + private static AtomicBoolean isRefreshing = new AtomicBoolean(false); + + /** + * Refreshes a given list of Feeds in a separate Thread. This method might ignore subsequent calls if it is still + * enqueuing Feeds for download from a previous call + * + * @param context Might be used for accessing the database + * @param feeds List of Feeds that should be refreshed. + */ + public static void refreshAllFeeds(final Context context, + final List<Feed> feeds) { + if (isRefreshing.compareAndSet(false, true)) { + new Thread() { + public void run() { + if (feeds != null) { + refreshFeeds(context, feeds); + } else { + refreshFeeds(context, DBReader.getFeedList(context)); + } + isRefreshing.set(false); + + if (FlattrUtils.hasToken()) { + if (BuildConfig.DEBUG) Log.d(TAG, "Flattring all pending things."); + new FlattrClickWorker(context).executeAsync(); // flattr pending things + + if (BuildConfig.DEBUG) Log.d(TAG, "Fetching flattr status."); + new FlattrStatusFetcher(context).start(); + + } + GpodnetSyncService.sendSyncIntent(context); + autodownloadUndownloadedItems(context); + } + }.start(); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Ignoring request to refresh all feeds: Refresh lock is locked"); + } + } + + /** + * Used by refreshExpiredFeeds to determine which feeds should be refreshed. + * This method will use the value specified in the UserPreferences as the + * expiration time. + * + * @param context Used for DB access. + * @return A list of expired feeds. An empty list will be returned if there + * are no expired feeds. + */ + public static List<Feed> getExpiredFeeds(final Context context) { + long millis = UserPreferences.getUpdateInterval(); + + if (millis > 0) { + + List<Feed> feedList = DBReader.getExpiredFeedsList(context, + millis); + if (feedList.size() > 0) { + refreshFeeds(context, feedList); + } + return feedList; + } else { + return new ArrayList<Feed>(); + } + } + + /** + * Refreshes expired Feeds in the list returned by the getExpiredFeedsList(Context, long) method in DBReader. + * The expiration date parameter is determined by the update interval specified in {@link UserPreferences}. + * + * @param context Used for DB access. + */ + public static void refreshExpiredFeeds(final Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Refreshing expired feeds"); + + new Thread() { + public void run() { + refreshFeeds(context, getExpiredFeeds(context)); + } + }.start(); + } + + private static void refreshFeeds(final Context context, + final List<Feed> feedList) { + + for (Feed feed : feedList) { + try { + refreshFeed(context, feed); + } catch (DownloadRequestException e) { + e.printStackTrace(); + DBWriter.addDownloadStatus( + context, + new DownloadStatus(feed, feed + .getHumanReadableIdentifier(), + DownloadError.ERROR_REQUEST_ERROR, false, e + .getMessage() + ) + ); + } + } + + } + + /** + * Updates a specific Feed. + * + * @param context Used for requesting the download. + * @param feed The Feed object. + */ + public static void refreshFeed(Context context, Feed feed) + throws DownloadRequestException { + Feed f; + if (feed.getPreferences() == null) { + f = new Feed(feed.getDownload_url(), new Date(), feed.getTitle()); + } else { + f = new Feed(feed.getDownload_url(), new Date(), feed.getTitle(), + feed.getPreferences().getUsername(), feed.getPreferences().getPassword()); + } + f.setId(feed.getId()); + DownloadRequester.getInstance().downloadFeed(context, f); + } + + /** + * Notifies the database about a missing FeedImage file. This method will attempt to re-download the file. + * + * @param context Used for requesting the download. + * @param image The FeedImage object. + */ + public static void notifyInvalidImageFile(final Context context, + final FeedImage image) { + Log.i(TAG, + "The DB was notified about an invalid image download. It will now try to re-download the image file"); + try { + DownloadRequester.getInstance().downloadImage(context, image); + } catch (DownloadRequestException e) { + e.printStackTrace(); + Log.w(TAG, "Failed to download invalid feed image"); + } + } + + /** + * Notifies the database about a missing FeedMedia file. This method will correct the FeedMedia object's values in the + * DB and send a FeedUpdateBroadcast. + */ + public static void notifyMissingFeedMediaFile(final Context context, + final FeedMedia media) { + Log.i(TAG, + "The feedmanager was notified about a missing episode. It will update its database now."); + media.setDownloaded(false); + media.setFile_url(null); + DBWriter.setFeedMedia(context, media); + EventDistributor.getInstance().sendFeedUpdateBroadcast(); + } + + /** + * Request the download of all objects in the queue. from a separate Thread. + * + * @param context Used for requesting the download an accessing the database. + */ + public static void downloadAllItemsInQueue(final Context context) { + new Thread() { + public void run() { + List<FeedItem> queue = DBReader.getQueue(context); + if (!queue.isEmpty()) { + try { + downloadFeedItems(context, + queue.toArray(new FeedItem[queue.size()])); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + } + } + }.start(); + } + + /** + * Requests the download of a list of FeedItem objects. + * + * @param context Used for requesting the download and accessing the DB. + * @param items The FeedItem objects. + */ + public static void downloadFeedItems(final Context context, + FeedItem... items) throws DownloadRequestException { + downloadFeedItems(true, context, items); + } + + private static void downloadFeedItems(boolean performAutoCleanup, + final Context context, final FeedItem... items) + throws DownloadRequestException { + final DownloadRequester requester = DownloadRequester.getInstance(); + + if (performAutoCleanup) { + new Thread() { + + @Override + public void run() { + performAutoCleanup(context, + getPerformAutoCleanupArgs(context, items.length)); + } + + }.start(); + } + for (FeedItem item : items) { + if (item.getMedia() != null + && !requester.isDownloadingFile(item.getMedia()) + && !item.getMedia().isDownloaded()) { + if (items.length > 1) { + try { + requester.downloadMedia(context, item.getMedia()); + } catch (DownloadRequestException e) { + e.printStackTrace(); + DBWriter.addDownloadStatus(context, + new DownloadStatus(item.getMedia(), item + .getMedia() + .getHumanReadableIdentifier(), + DownloadError.ERROR_REQUEST_ERROR, + false, e.getMessage() + ) + ); + } + } else { + requester.downloadMedia(context, item.getMedia()); + } + } + } + } + + private static int getNumberOfUndownloadedEpisodes( + final List<FeedItem> queue, final List<FeedItem> unreadItems) { + int counter = 0; + for (FeedItem item : queue) { + if (item.hasMedia() && !item.getMedia().isDownloaded() + && !item.getMedia().isPlaying() + && item.getFeed().getPreferences().getAutoDownload()) { + counter++; + } + } + for (FeedItem item : unreadItems) { + if (item.hasMedia() && !item.getMedia().isDownloaded() + && item.getFeed().getPreferences().getAutoDownload()) { + counter++; + } + } + return counter; + } + + /** + * Looks for undownloaded episodes in the queue or list of unread items and request a download if + * 1. Network is available + * 2. There is free space in the episode cache + * This method is executed on an internal single thread executor. + * + * @param context Used for accessing the DB. + * @param mediaIds If this list is not empty, the method will only download a candidate for automatic downloading if + * its media ID is in the mediaIds list. + * @return A Future that can be used for waiting for the methods completion. + */ + public static Future<?> autodownloadUndownloadedItems(final Context context, final long... mediaIds) { + return autodownloadExec.submit(new Runnable() { + @Override + public void run() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Performing auto-dl of undownloaded episodes"); + if (NetworkUtils.autodownloadNetworkAvailable(context) + && UserPreferences.isEnableAutodownload()) { + final List<FeedItem> queue = DBReader.getQueue(context); + final List<FeedItem> unreadItems = DBReader + .getUnreadItemsList(context); + + int undownloadedEpisodes = getNumberOfUndownloadedEpisodes(queue, + unreadItems); + int downloadedEpisodes = DBReader + .getNumberOfDownloadedEpisodes(context); + int deletedEpisodes = performAutoCleanup(context, + getPerformAutoCleanupArgs(context, undownloadedEpisodes)); + int episodeSpaceLeft = undownloadedEpisodes; + boolean cacheIsUnlimited = UserPreferences.getEpisodeCacheSize() == UserPreferences + .getEpisodeCacheSizeUnlimited(); + + if (!cacheIsUnlimited + && UserPreferences.getEpisodeCacheSize() < downloadedEpisodes + + undownloadedEpisodes) { + episodeSpaceLeft = UserPreferences.getEpisodeCacheSize() + - (downloadedEpisodes - deletedEpisodes); + } + + Arrays.sort(mediaIds); // sort for binary search + final boolean ignoreMediaIds = mediaIds.length == 0; + List<FeedItem> itemsToDownload = new ArrayList<FeedItem>(); + + if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) { + for (int i = 0; i < queue.size(); i++) { // ignore playing item + FeedItem item = queue.get(i); + long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1; + if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0) + && item.hasMedia() + && !item.getMedia().isDownloaded() + && !item.getMedia().isPlaying() + && item.getFeed().getPreferences().getAutoDownload()) { + itemsToDownload.add(item); + episodeSpaceLeft--; + undownloadedEpisodes--; + if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) { + break; + } + } + } + } + + if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) { + for (FeedItem item : unreadItems) { + long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1; + if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0) + && item.hasMedia() + && !item.getMedia().isDownloaded() + && item.getFeed().getPreferences().getAutoDownload()) { + itemsToDownload.add(item); + episodeSpaceLeft--; + undownloadedEpisodes--; + if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) { + break; + } + } + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Enqueueing " + itemsToDownload.size() + + " items for download"); + + try { + downloadFeedItems(false, context, + itemsToDownload.toArray(new FeedItem[itemsToDownload + .size()]) + ); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + + } + } + }); + + } + + private static int getPerformAutoCleanupArgs(Context context, + final int episodeNumber) { + if (episodeNumber >= 0 + && UserPreferences.getEpisodeCacheSize() != UserPreferences + .getEpisodeCacheSizeUnlimited()) { + int downloadedEpisodes = DBReader + .getNumberOfDownloadedEpisodes(context); + if (downloadedEpisodes + episodeNumber >= UserPreferences + .getEpisodeCacheSize()) { + + return downloadedEpisodes + episodeNumber + - UserPreferences.getEpisodeCacheSize(); + } + } + return 0; + } + + /** + * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller + * 'playbackCompletionDate'-value will be deleted first. + * <p/> + * This method should NOT be executed on the GUI thread. + * + * @param context Used for accessing the DB. + */ + public static void performAutoCleanup(final Context context) { + performAutoCleanup(context, getPerformAutoCleanupArgs(context, 0)); + } + + private static int performAutoCleanup(final Context context, + final int episodeNumber) { + List<FeedItem> candidates = new ArrayList<FeedItem>(); + List<FeedItem> downloadedItems = DBReader.getDownloadedItems(context); + QueueAccess queue = QueueAccess.IDListAccess(DBReader.getQueueIDList(context)); + List<FeedItem> delete; + for (FeedItem item : downloadedItems) { + if (item.hasMedia() && item.getMedia().isDownloaded() + && !queue.contains(item.getId()) && item.isRead()) { + candidates.add(item); + } + + } + + Collections.sort(candidates, new Comparator<FeedItem>() { + @Override + public int compare(FeedItem lhs, FeedItem rhs) { + Date l = lhs.getMedia().getPlaybackCompletionDate(); + Date r = rhs.getMedia().getPlaybackCompletionDate(); + + if (l == null) { + l = new Date(0); + } + if (r == null) { + r = new Date(0); + } + return l.compareTo(r); + } + }); + + if (candidates.size() > episodeNumber) { + delete = candidates.subList(0, episodeNumber); + } else { + delete = candidates; + } + + for (FeedItem item : delete) { + try { + DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + + int counter = delete.size(); + + if (BuildConfig.DEBUG) + Log.d(TAG, String.format( + "Auto-delete deleted %d episodes (%d requested)", counter, + episodeNumber)); + + return counter; + } + + /** + * Adds all FeedItem objects whose 'read'-attribute is false to the queue in a separate thread. + */ + public static void enqueueAllNewItems(final Context context) { + long[] unreadItems = DBReader.getUnreadItemIds(context); + DBWriter.addQueueItem(context, unreadItems); + } + + /** + * Returns the successor of a FeedItem in the queue. + * + * @param context Used for accessing the DB. + * @param itemId ID of the FeedItem + * @param queue Used for determining the successor of the item. If this parameter is null, the method will load + * the queue from the database in the same thread. + * @return Successor of the FeedItem or null if the FeedItem is not in the queue or has no successor. + */ + public static FeedItem getQueueSuccessorOfItem(Context context, + final long itemId, List<FeedItem> queue) { + FeedItem result = null; + if (queue == null) { + queue = DBReader.getQueue(context); + } + if (queue != null) { + Iterator<FeedItem> iterator = queue.iterator(); + while (iterator.hasNext()) { + FeedItem item = iterator.next(); + if (item.getId() == itemId) { + if (iterator.hasNext()) { + result = iterator.next(); + } + break; + } + } + } + return result; + } + + /** + * Loads the queue from the database and checks if the specified FeedItem is in the queue. + * This method should NOT be executed in the GUI thread. + * + * @param context Used for accessing the DB. + * @param feedItemId ID of the FeedItem + */ + public static boolean isInQueue(Context context, final long feedItemId) { + List<Long> queue = DBReader.getQueueIDList(context); + return QueueAccess.IDListAccess(queue).contains(feedItemId); + } + + private static Feed searchFeedByIdentifyingValueOrID(Context context, PodDBAdapter adapter, + Feed feed) { + if (feed.getId() != 0) { + return DBReader.getFeed(context, feed.getId(), adapter); + } else { + List<Feed> feeds = DBReader.getFeedList(context); + for (Feed f : feeds) { + if (f.getIdentifyingValue().equals(feed.getIdentifyingValue())) { + f.setItems(DBReader.getFeedItemList(context, f)); + return f; + } + } + } + return null; + } + + /** + * Get a FeedItem by its identifying value. + */ + private static FeedItem searchFeedItemByIdentifyingValue(Feed feed, + String identifier) { + for (FeedItem item : feed.getItems()) { + if (item.getIdentifyingValue().equals(identifier)) { + return item; + } + } + return null; + } + + /** + * Adds new Feeds to the database or updates the old versions if they already exists. If another Feed with the same + * identifying value already exists, this method will add new FeedItems from the new Feed to the existing Feed. + * These FeedItems will be marked as unread. + * <p/> + * This method can update multiple feeds at once. Submitting a feed twice in the same method call can result in undefined behavior. + * <p/> + * This method should NOT be executed on the GUI thread. + * + * @param context Used for accessing the DB. + * @param newFeeds The new Feed objects. + * @return The updated Feeds from the database if it already existed, or the new Feed from the parameters otherwise. + */ + public static synchronized Feed[] updateFeed(final Context context, + final Feed... newFeeds) { + List<Feed> newFeedsList = new ArrayList<Feed>(); + List<Feed> updatedFeedsList = new ArrayList<Feed>(); + Feed[] resultFeeds = new Feed[newFeeds.length]; + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + for (int feedIdx = 0; feedIdx < newFeeds.length; feedIdx++) { + + final Feed newFeed = newFeeds[feedIdx]; + + // Look up feed in the feedslist + final Feed savedFeed = searchFeedByIdentifyingValueOrID(context, adapter, + newFeed); + if (savedFeed == null) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Found no existing Feed with title " + + newFeed.getTitle() + ". Adding as new one." + ); + // Add a new Feed + newFeedsList.add(newFeed); + resultFeeds[feedIdx] = newFeed; + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Feed with title " + newFeed.getTitle() + + " already exists. Syncing new with existing one."); + + Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator()); + if (savedFeed.compareWithOther(newFeed)) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Feed has updated attribute values. Updating old feed's attributes"); + savedFeed.updateFromOther(newFeed); + } + if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences"); + savedFeed.getPreferences().updateFromOther(newFeed.getPreferences()); + } + // Look for new or updated Items + for (int idx = 0; idx < newFeed.getItems().size(); idx++) { + final FeedItem item = newFeed.getItems().get(idx); + FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed, + item.getIdentifyingValue()); + if (oldItem == null) { + // item is new + final int i = idx; + item.setFeed(savedFeed); + savedFeed.getItems().add(i, item); + item.setRead(false); + } else { + oldItem.updateFromOther(item); + } + } + // update attributes + savedFeed.setLastUpdate(newFeed.getLastUpdate()); + savedFeed.setType(newFeed.getType()); + + updatedFeedsList.add(savedFeed); + resultFeeds[feedIdx] = savedFeed; + } + } + + adapter.close(); + + try { + DBWriter.addNewFeed(context, newFeedsList.toArray(new Feed[newFeedsList.size()])).get(); + DBWriter.setCompleteFeed(context, updatedFeedsList.toArray(new Feed[updatedFeedsList.size()])).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + + EventDistributor.getInstance().sendFeedUpdateBroadcast(); + + return resultFeeds; + } + + /** + * Searches the titles of FeedItems of a specific Feed for a given + * string. + * + * @param context Used for accessing the DB. + * @param feedID The id of the feed whose items should be searched. + * @param query The search string. + * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. + */ + public static FutureTask<List<FeedItem>> searchFeedItemTitle(final Context context, + final long feedID, final String query) { + return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemTitles(feedID, + query); + List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult); + DBReader.loadFeedDataOfFeedItemlist(context, items); + setResult(items); + searchResult.close(); + } + }); + } + + /** + * Searches the descriptions of FeedItems of a specific Feed for a given + * string. + * + * @param context Used for accessing the DB. + * @param feedID The id of the feed whose items should be searched. + * @param query The search string + * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. + */ + public static FutureTask<List<FeedItem>> searchFeedItemDescription(final Context context, + final long feedID, final String query) { + return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemDescriptions(feedID, + query); + List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult); + DBReader.loadFeedDataOfFeedItemlist(context, items); + setResult(items); + searchResult.close(); + } + }); + } + + /** + * Searches the contentEncoded-value of FeedItems of a specific Feed for a given + * string. + * + * @param context Used for accessing the DB. + * @param feedID The id of the feed whose items should be searched. + * @param query The search string + * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. + */ + public static FutureTask<List<FeedItem>> searchFeedItemContentEncoded(final Context context, + final long feedID, final String query) { + return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemContentEncoded(feedID, + query); + List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult); + DBReader.loadFeedDataOfFeedItemlist(context, items); + setResult(items); + searchResult.close(); + } + }); + } + + /** + * Searches chapters of the FeedItems of a specific Feed for a given string. + * + * @param context Used for accessing the DB. + * @param feedID The id of the feed whose items should be searched. + * @param query The search string + * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. + */ + public static FutureTask<List<FeedItem>> searchFeedItemChapters(final Context context, + final long feedID, final String query) { + return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemChapters(feedID, + query); + List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult); + DBReader.loadFeedDataOfFeedItemlist(context, items); + setResult(items); + searchResult.close(); + } + }); + } + + /** + * A runnable which should be used for database queries. The onCompletion + * method is executed on the database executor to handle Cursors correctly. + * This class automatically creates a PodDBAdapter object and closes it when + * it is no longer in use. + */ + static abstract class QueryTask<T> implements Callable<T> { + private T result; + private Context context; + + public QueryTask(Context context) { + this.context = context; + } + + @Override + public T call() throws Exception { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + execute(adapter); + adapter.close(); + return result; + } + + public abstract void execute(PodDBAdapter adapter); + + protected void setResult(T result) { + this.result = result; + } + } + + /** + * Adds the given FeedItem to the flattr queue if the user is logged in. Otherwise, a dialog + * will be opened that lets the user go either to the login screen or the website of the flattr thing. + * + * @param context + * @param item + */ + public static void flattrItemIfLoggedIn(Context context, FeedItem item) { + if (FlattrUtils.hasToken()) { + item.getFlattrStatus().setFlattrQueue(); + DBWriter.setFlattredStatus(context, item, true); + } else { + FlattrUtils.showNoTokenDialogOrRedirect(context, item.getPaymentLink()); + } + } + + /** + * Adds the given Feed to the flattr queue if the user is logged in. Otherwise, a dialog + * will be opened that lets the user go either to the login screen or the website of the flattr thing. + * + * @param context + * @param feed + */ + public static void flattrFeedIfLoggedIn(Context context, Feed feed) { + if (FlattrUtils.hasToken()) { + feed.getFlattrStatus().setFlattrQueue(); + DBWriter.setFlattredStatus(context, feed, true); + } else { + FlattrUtils.showNoTokenDialogOrRedirect(context, feed.getPaymentLink()); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java b/app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java new file mode 100644 index 000000000..9916ac97f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java @@ -0,0 +1,974 @@ +package de.danoeh.antennapod.storage; + +import android.app.backup.BackupManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.preference.PreferenceManager; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.asynctask.FlattrClickWorker; +import de.danoeh.antennapod.feed.*; +import de.danoeh.antennapod.preferences.GpodnetPreferences; +import de.danoeh.antennapod.preferences.PlaybackPreferences; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.flattr.FlattrStatus; +import de.danoeh.antennapod.util.flattr.FlattrThing; +import de.danoeh.antennapod.util.flattr.SimpleFlattrThing; +import org.shredzone.flattr4j.model.Flattr; + +import java.io.File; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; + +/** + * Provides methods for writing data to AntennaPod's database. + * In general, DBWriter-methods will be executed on an internal ExecutorService. + * Some methods return a Future-object which the caller can use for waiting for the method's completion. The returned Future's + * will NOT contain any results. + * The caller can also use the {@link EventDistributor} in order to be notified about the method's completion asynchronously. + * This class will use the {@link EventDistributor} to notify listeners about changes in the database. + */ +public class DBWriter { + private static final String TAG = "DBWriter"; + + private static final ExecutorService dbExec; + + static { + dbExec = Executors.newSingleThreadExecutor(new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + }); + } + + private DBWriter() { + } + + /** + * Deletes a downloaded FeedMedia file from the storage device. + * + * @param context A context that is used for opening a database connection. + * @param mediaId ID of the FeedMedia object whose downloaded file should be deleted. + */ + public static Future<?> deleteFeedMediaOfItem(final Context context, + final long mediaId) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + + final FeedMedia media = DBReader.getFeedMedia(context, mediaId); + if (media != null) { + Log.i(TAG, String.format("Requested to delete FeedMedia [id=%d, title=%s, downloaded=%s", + media.getId(), media.getEpisodeTitle(), String.valueOf(media.isDownloaded()))); + boolean result = false; + if (media.isDownloaded()) { + // delete downloaded media file + File mediaFile = new File(media.getFile_url()); + if (mediaFile.exists()) { + result = mediaFile.delete(); + } + media.setDownloaded(false); + media.setFile_url(null); + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setMedia(media); + adapter.close(); + + // If media is currently being played, change playback + // type to 'stream' and shutdown playback service + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(context); + if (PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA) { + if (media.getId() == PlaybackPreferences + .getCurrentlyPlayingFeedMediaId()) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean( + PlaybackPreferences.PREF_CURRENT_EPISODE_IS_STREAM, + true); + editor.commit(); + } + if (PlaybackPreferences + .getCurrentlyPlayingFeedMediaId() == media + .getId()) { + context.sendBroadcast(new Intent( + PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + } + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Deleting File. Result: " + result); + EventDistributor.getInstance().sendQueueUpdateBroadcast(); + EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast(); + } + } + }); + } + + /** + * Deletes a Feed and all downloaded files of its components like images and downloaded episodes. + * + * @param context A context that is used for opening a database connection. + * @param feedId ID of the Feed that should be deleted. + */ + public static Future<?> deleteFeed(final Context context, final long feedId) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + DownloadRequester requester = DownloadRequester.getInstance(); + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(context + .getApplicationContext()); + final Feed feed = DBReader.getFeed(context, feedId); + if (feed != null) { + if (PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA + && PlaybackPreferences.getLastPlayedFeedId() == feed + .getId()) { + context.sendBroadcast(new Intent( + PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + -1); + editor.commit(); + } + + // delete image file + if (feed.getImage() != null) { + if (feed.getImage().isDownloaded() + && feed.getImage().getFile_url() != null) { + File imageFile = new File(feed.getImage() + .getFile_url()); + imageFile.delete(); + } else if (requester.isDownloadingFile(feed.getImage())) { + requester.cancelDownload(context, feed.getImage()); + } + } + // delete stored media files and mark them as read + List<FeedItem> queue = DBReader.getQueue(context); + boolean queueWasModified = false; + if (feed.getItems() == null) { + DBReader.getFeedItemList(context, feed); + } + + for (FeedItem item : feed.getItems()) { + queueWasModified |= queue.remove(item); + if (item.getMedia() != null + && item.getMedia().isDownloaded()) { + File mediaFile = new File(item.getMedia() + .getFile_url()); + mediaFile.delete(); + } else if (item.getMedia() != null + && requester.isDownloadingFile(item.getMedia())) { + requester.cancelDownload(context, item.getMedia()); + } + + if (item.hasItemImage()) { + FeedImage image = item.getImage(); + if (image.isDownloaded() && image.getFile_url() != null) { + File imgFile = new File(image.getFile_url()); + imgFile.delete(); + } else if (requester.isDownloadingFile(image)) { + requester.cancelDownload(context, item.getImage()); + } + } + } + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + if (queueWasModified) { + adapter.setQueue(queue); + } + adapter.removeFeed(feed); + adapter.close(); + + GpodnetPreferences.addRemovedFeed(feed.getDownload_url()); + EventDistributor.getInstance().sendFeedUpdateBroadcast(); + + BackupManager backupManager = new BackupManager(context); + backupManager.dataChanged(); + } + } + }); + } + + /** + * Deletes the entire playback history. + * + * @param context A context that is used for opening a database connection. + */ + public static Future<?> clearPlaybackHistory(final Context context) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.clearPlaybackHistory(); + adapter.close(); + EventDistributor.getInstance() + .sendPlaybackHistoryUpdateBroadcast(); + } + }); + } + + /** + * Adds a FeedMedia object to the playback history. A FeedMedia object is in the playback history if + * its playback completion date is set to a non-null value. This method will set the playback completion date to the + * current date regardless of the current value. + * + * @param context A context that is used for opening a database connection. + * @param media FeedMedia that should be added to the playback history. + */ + public static Future<?> addItemToPlaybackHistory(final Context context, + final FeedMedia media) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Adding new item to playback history"); + media.setPlaybackCompletionDate(new Date()); + // reset played_duration to 0 so that it behaves correctly when the episode is played again + media.setPlayedDuration(0); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setFeedMediaPlaybackCompletionDate(media); + adapter.close(); + EventDistributor.getInstance().sendPlaybackHistoryUpdateBroadcast(); + + } + }); + } + + private static void cleanupDownloadLog(final PodDBAdapter adapter) { + final long logSize = adapter.getDownloadLogSize(); + if (logSize > DBReader.DOWNLOAD_LOG_SIZE) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Cleaning up download log"); + adapter.removeDownloadLogItems(logSize - DBReader.DOWNLOAD_LOG_SIZE); + } + } + + /** + * Adds a Download status object to the download log. + * + * @param context A context that is used for opening a database connection. + * @param status The DownloadStatus object. + */ + public static Future<?> addDownloadStatus(final Context context, + final DownloadStatus status) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setDownloadStatus(status); + adapter.close(); + EventDistributor.getInstance().sendDownloadLogUpdateBroadcast(); + } + }); + + } + + /** + * Inserts a FeedItem in the queue at the specified index. The 'read'-attribute of the FeedItem will be set to + * true. If the FeedItem is already in the queue, the queue will not be modified. + * + * @param context A context that is used for opening a database connection. + * @param itemId ID of the FeedItem that should be added to the queue. + * @param index Destination index. Must be in range 0..queue.size() + * @param performAutoDownload True if an auto-download process should be started after the operation + * @throws IndexOutOfBoundsException if index < 0 || index >= queue.size() + */ + public static Future<?> addQueueItemAt(final Context context, final long itemId, + final int index, final boolean performAutoDownload) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + final List<FeedItem> queue = DBReader + .getQueue(context, adapter); + FeedItem item = null; + + if (queue != null) { + boolean queueModified = false; + boolean unreadItemsModified = false; + + if (!itemListContains(queue, itemId)) { + item = DBReader.getFeedItem(context, itemId); + if (item != null) { + queue.add(index, item); + queueModified = true; + if (!item.isRead()) { + item.setRead(true); + unreadItemsModified = true; + } + } + } + if (queueModified) { + adapter.setQueue(queue); + EventDistributor.getInstance() + .sendQueueUpdateBroadcast(); + } + if (unreadItemsModified && item != null) { + adapter.setSingleFeedItem(item); + EventDistributor.getInstance() + .sendUnreadItemsUpdateBroadcast(); + } + } + adapter.close(); + if (performAutoDownload) { + DBTasks.autodownloadUndownloadedItems(context); + } + + } + }); + + } + + /** + * Appends FeedItem objects to the end of the queue. The 'read'-attribute of all items will be set to true. + * If a FeedItem is already in the queue, the FeedItem will not change its position in the queue. + * + * @param context A context that is used for opening a database connection. + * @param itemIds IDs of the FeedItem objects that should be added to the queue. + */ + public static Future<?> addQueueItem(final Context context, + final long... itemIds) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + if (itemIds.length > 0) { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + final List<FeedItem> queue = DBReader.getQueue(context, + adapter); + + if (queue != null) { + boolean queueModified = false; + boolean unreadItemsModified = false; + List<FeedItem> itemsToSave = new LinkedList<FeedItem>(); + for (int i = 0; i < itemIds.length; i++) { + if (!itemListContains(queue, itemIds[i])) { + final FeedItem item = DBReader.getFeedItem( + context, itemIds[i]); + + if (item != null) { + queue.add(item); + queueModified = true; + if (!item.isRead()) { + item.setRead(true); + itemsToSave.add(item); + unreadItemsModified = true; + } + } + } + } + if (queueModified) { + adapter.setQueue(queue); + EventDistributor.getInstance() + .sendQueueUpdateBroadcast(); + } + if (unreadItemsModified) { + adapter.setFeedItemlist(itemsToSave); + EventDistributor.getInstance() + .sendUnreadItemsUpdateBroadcast(); + } + } + adapter.close(); + DBTasks.autodownloadUndownloadedItems(context); + } + } + }); + + } + + /** + * Removes all FeedItem objects from the queue. + * + * @param context A context that is used for opening a database connection. + */ + public static Future<?> clearQueue(final Context context) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.clearQueue(); + adapter.close(); + + EventDistributor.getInstance().sendQueueUpdateBroadcast(); + } + }); + } + + /** + * Removes a FeedItem object from the queue. + * + * @param context A context that is used for opening a database connection. + * @param itemId ID of the FeedItem that should be removed. + * @param performAutoDownload true if an auto-download process should be started after the operation. + */ + public static Future<?> removeQueueItem(final Context context, + final long itemId, final boolean performAutoDownload) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + final List<FeedItem> queue = DBReader + .getQueue(context, adapter); + FeedItem item = null; + + if (queue != null) { + boolean queueModified = false; + QueueAccess queueAccess = QueueAccess.ItemListAccess(queue); + if (queueAccess.contains(itemId)) { + item = DBReader.getFeedItem(context, itemId); + if (item != null) { + queueModified = queueAccess.remove(itemId); + } + } + if (queueModified) { + adapter.setQueue(queue); + EventDistributor.getInstance() + .sendQueueUpdateBroadcast(); + } else { + Log.w(TAG, "Queue was not modified by call to removeQueueItem"); + } + } else { + Log.e(TAG, "removeQueueItem: Could not load queue"); + } + adapter.close(); + if (performAutoDownload) { + DBTasks.autodownloadUndownloadedItems(context); + } + } + }); + + } + + /** + * Moves the specified item to the top of the queue. + * + * @param context A context that is used for opening a database connection. + * @param itemId The item to move to the top of the queue + * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to + * false if the caller wants to avoid unexpected updates of the GUI. + */ + public static Future<?> moveQueueItemToTop(final Context context, final long itemId, final boolean broadcastUpdate) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + List<Long> queueIdList = DBReader.getQueueIDList(context); + int currentLocation = 0; + for (long id : queueIdList) { + if (id == itemId) { + moveQueueItemHelper(context, currentLocation, 0, broadcastUpdate); + return; + } + currentLocation++; + } + Log.e(TAG, "moveQueueItemToTop: item not found"); + } + }); + } + + /** + * Moves the specified item to the bottom of the queue. + * + * @param context A context that is used for opening a database connection. + * @param itemId The item to move to the bottom of the queue + * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to + * false if the caller wants to avoid unexpected updates of the GUI. + */ + public static Future<?> moveQueueItemToBottom(final Context context, final long itemId, + final boolean broadcastUpdate) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + List<Long> queueIdList = DBReader.getQueueIDList(context); + int currentLocation = 0; + for (long id : queueIdList) { + if (id == itemId) { + moveQueueItemHelper(context, currentLocation, queueIdList.size() - 1, + broadcastUpdate); + return; + } + currentLocation++; + } + Log.e(TAG, "moveQueueItemToBottom: item not found"); + } + }); + } + + /** + * Changes the position of a FeedItem in the queue. + * + * @param context A context that is used for opening a database connection. + * @param from Source index. Must be in range 0..queue.size()-1. + * @param to Destination index. Must be in range 0..queue.size()-1. + * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to + * false if the caller wants to avoid unexpected updates of the GUI. + * @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size()) + */ + public static Future<?> moveQueueItem(final Context context, final int from, + final int to, final boolean broadcastUpdate) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + moveQueueItemHelper(context, from, to, broadcastUpdate); + } + }); + } + + /** + * Changes the position of a FeedItem in the queue. + * <p/> + * This function must be run using the ExecutorService (dbExec). + * + * @param context A context that is used for opening a database connection. + * @param from Source index. Must be in range 0..queue.size()-1. + * @param to Destination index. Must be in range 0..queue.size()-1. + * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to + * false if the caller wants to avoid unexpected updates of the GUI. + * @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size()) + */ + private static void moveQueueItemHelper(final Context context, final int from, + final int to, final boolean broadcastUpdate) { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + final List<FeedItem> queue = DBReader + .getQueue(context, adapter); + + if (queue != null) { + if (from >= 0 && from < queue.size() && to >= 0 + && to < queue.size()) { + + final FeedItem item = queue.remove(from); + queue.add(to, item); + + adapter.setQueue(queue); + if (broadcastUpdate) { + EventDistributor.getInstance() + .sendQueueUpdateBroadcast(); + } + + } + } else { + Log.e(TAG, "moveQueueItemHelper: Could not load queue"); + } + adapter.close(); + } + + /** + * Sets the 'read'-attribute of a FeedItem to the specified value. + * + * @param context A context that is used for opening a database connection. + * @param item The FeedItem object + * @param read New value of the 'read'-attribute + * @param resetMediaPosition true if this method should also reset the position of the FeedItem's FeedMedia object. + * If the FeedItem has no FeedMedia object, this parameter will be ignored. + */ + public static Future<?> markItemRead(Context context, FeedItem item, boolean read, boolean resetMediaPosition) { + long mediaId = (item.hasMedia()) ? item.getMedia().getId() : 0; + return markItemRead(context, item.getId(), read, mediaId, resetMediaPosition); + } + + /** + * Sets the 'read'-attribute of a FeedItem to the specified value. + * + * @param context A context that is used for opening a database connection. + * @param itemId ID of the FeedItem + * @param read New value of the 'read'-attribute + */ + public static Future<?> markItemRead(final Context context, final long itemId, + final boolean read) { + return markItemRead(context, itemId, read, 0, false); + } + + private static Future<?> markItemRead(final Context context, final long itemId, + final boolean read, final long mediaId, + final boolean resetMediaPosition) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setFeedItemRead(read, itemId, mediaId, + resetMediaPosition); + adapter.close(); + + EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast(); + } + }); + } + + /** + * Sets the 'read'-attribute of all FeedItems of a specific Feed to true. + * + * @param context A context that is used for opening a database connection. + * @param feedId ID of the Feed. + */ + public static Future<?> markFeedRead(final Context context, final long feedId) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor itemCursor = adapter.getAllItemsOfFeedCursor(feedId); + long[] itemIds = new long[itemCursor.getCount()]; + itemCursor.moveToFirst(); + for (int i = 0; i < itemIds.length; i++) { + itemIds[i] = itemCursor.getLong(PodDBAdapter.KEY_ID_INDEX); + itemCursor.moveToNext(); + } + itemCursor.close(); + adapter.setFeedItemRead(true, itemIds); + adapter.close(); + + EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast(); + } + }); + + } + + /** + * Sets the 'read'-attribute of all FeedItems to true. + * + * @param context A context that is used for opening a database connection. + */ + public static Future<?> markAllItemsRead(final Context context) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor itemCursor = adapter.getUnreadItemsCursor(); + long[] itemIds = new long[itemCursor.getCount()]; + itemCursor.moveToFirst(); + for (int i = 0; i < itemIds.length; i++) { + itemIds[i] = itemCursor.getLong(PodDBAdapter.KEY_ID_INDEX); + itemCursor.moveToNext(); + } + itemCursor.close(); + adapter.setFeedItemRead(true, itemIds); + adapter.close(); + + EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast(); + } + }); + + } + + static Future<?> addNewFeed(final Context context, final Feed... feeds) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + final PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setCompleteFeed(feeds); + adapter.close(); + + for (Feed feed : feeds) { + GpodnetPreferences.addAddedFeed(feed.getDownload_url()); + } + + BackupManager backupManager = new BackupManager(context); + backupManager.dataChanged(); + } + }); + } + + static Future<?> setCompleteFeed(final Context context, final Feed... feeds) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setCompleteFeed(feeds); + adapter.close(); + + } + }); + + } + + /** + * Saves a FeedMedia object in the database. This method will save all attributes of the FeedMedia object. The + * contents of FeedComponent-attributes (e.g. the FeedMedia's 'item'-attribute) will not be saved. + * + * @param context A context that is used for opening a database connection. + * @param media The FeedMedia object. + */ + public static Future<?> setFeedMedia(final Context context, + final FeedMedia media) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setMedia(media); + adapter.close(); + } + }); + } + + /** + * Saves the 'position' and 'duration' attributes of a FeedMedia object + * + * @param context A context that is used for opening a database connection. + * @param media The FeedMedia object. + */ + public static Future<?> setFeedMediaPlaybackInformation(final Context context, final FeedMedia media) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setFeedMediaPlaybackInformation(media); + adapter.close(); + } + }); + } + + /** + * Saves a FeedItem object in the database. This method will save all attributes of the FeedItem object including + * the content of FeedComponent-attributes. + * + * @param context A context that is used for opening a database connection. + * @param item The FeedItem object. + */ + public static Future<?> setFeedItem(final Context context, + final FeedItem item) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setSingleFeedItem(item); + adapter.close(); + } + }); + } + + /** + * Saves a FeedImage object in the database. This method will save all attributes of the FeedImage object. The + * contents of FeedComponent-attributes (e.g. the FeedImages's 'feed'-attribute) will not be saved. + * + * @param context A context that is used for opening a database connection. + * @param image The FeedImage object. + */ + public static Future<?> setFeedImage(final Context context, + final FeedImage image) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setImage(image); + adapter.close(); + } + }); + } + + /** + * Updates download URLs of feeds from a given Map. The key of the Map is the original URL of the feed + * and the value is the updated URL + */ + public static Future<?> updateFeedDownloadURLs(final Context context, final Map<String, String> urls) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + for (String key : urls.keySet()) { + if (BuildConfig.DEBUG) Log.d(TAG, "Replacing URL " + key + " with url " + urls.get(key)); + + adapter.setFeedDownloadUrl(key, urls.get(key)); + } + adapter.close(); + } + }); + } + + /** + * Saves a FeedPreferences object in the database. The Feed ID of the FeedPreferences-object MUST NOT be 0. + * + * @param context Used for opening a database connection. + * @param preferences The FeedPreferences object. + */ + public static Future<?> setFeedPreferences(final Context context, final FeedPreferences preferences) { + return dbExec.submit(new Runnable() { + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setFeedPreferences(preferences); + adapter.close(); + EventDistributor.getInstance().sendFeedUpdateBroadcast(); + } + }); + } + + private static boolean itemListContains(List<FeedItem> items, long itemId) { + for (FeedItem item : items) { + if (item.getId() == itemId) { + return true; + } + } + return false; + } + + /** + * Saves the FlattrStatus of a FeedItem object in the database. + * + * @param startFlattrClickWorker true if FlattrClickWorker should be started after the FlattrStatus has been saved + */ + public static Future<?> setFeedItemFlattrStatus(final Context context, + final FeedItem item, + final boolean startFlattrClickWorker) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setFeedItemFlattrStatus(item); + adapter.close(); + if (startFlattrClickWorker) { + new FlattrClickWorker(context).executeAsync(); + } + } + }); + } + + /** + * Saves the FlattrStatus of a Feed object in the database. + * + * @param startFlattrClickWorker true if FlattrClickWorker should be started after the FlattrStatus has been saved + */ + private static Future<?> setFeedFlattrStatus(final Context context, + final Feed feed, + final boolean startFlattrClickWorker) { + return dbExec.submit(new Runnable() { + + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.setFeedFlattrStatus(feed); + adapter.close(); + if (startFlattrClickWorker) { + new FlattrClickWorker(context).executeAsync(); + } + } + }); + } + + /** + * format an url for querying the database + * (postfix a / and apply percent-encoding) + */ + private static String formatURIForQuery(String uri) { + try { + return URLEncoder.encode(uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri, "UTF-8"); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, e.getMessage()); + return ""; + } + } + + + /** + * Set flattr status of the passed thing (either a FeedItem or a Feed) + * + * @param context + * @param thing + * @param startFlattrClickWorker true if FlattrClickWorker should be started after the FlattrStatus has been saved + * @return + */ + public static Future<?> setFlattredStatus(Context context, FlattrThing thing, boolean startFlattrClickWorker) { + // must propagate this to back db + if (thing instanceof FeedItem) + return setFeedItemFlattrStatus(context, (FeedItem) thing, startFlattrClickWorker); + else if (thing instanceof Feed) + return setFeedFlattrStatus(context, (Feed) thing, startFlattrClickWorker); + else if (thing instanceof SimpleFlattrThing) { + } // SimpleFlattrThings are generated on the fly and do not have DB backing + else + Log.e(TAG, "flattrQueue processing - thing is neither FeedItem nor Feed nor SimpleFlattrThing"); + + return null; + } + + /** + * Reset flattr status to unflattrd for all items + */ + public static Future<?> clearAllFlattrStatus(final Context context) { + Log.d(TAG, "clearAllFlattrStatus()"); + return dbExec.submit(new Runnable() { + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + adapter.clearAllFlattrStatus(); + adapter.close(); + } + }); + } + + /** + * Set flattr status of the feeds/feeditems in flattrList to flattred at the given timestamp, + * where the information has been retrieved from the flattr API + */ + public static Future<?> setFlattredStatus(final Context context, final List<Flattr> flattrList) { + Log.d(TAG, "setFlattredStatus to status retrieved from flattr api running with " + flattrList.size() + " items"); + // clear flattr status in db + clearAllFlattrStatus(context); + + // submit list with flattred things having normalized URLs to db + return dbExec.submit(new Runnable() { + @Override + public void run() { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + for (Flattr flattr : flattrList) { + adapter.setItemFlattrStatus(formatURIForQuery(flattr.getThing().getUrl()), new FlattrStatus(flattr.getCreated().getTime())); + } + adapter.close(); + } + }); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java new file mode 100644 index 000000000..0ef766e58 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.storage; + +/** + * Thrown by the DownloadRequester if a download request contains invalid data + * or something went wrong while processing the request. + */ +public class DownloadRequestException extends Exception { + + public DownloadRequestException() { + super(); + } + + public DownloadRequestException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public DownloadRequestException(String detailMessage) { + super(detailMessage); + } + + public DownloadRequestException(Throwable throwable) { + super(throwable); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java new file mode 100644 index 000000000..d305c572b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java @@ -0,0 +1,367 @@ +package de.danoeh.antennapod.storage; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.webkit.URLUtil; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.*; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.download.DownloadRequest; +import de.danoeh.antennapod.service.download.DownloadService; +import de.danoeh.antennapod.util.FileNameGenerator; +import de.danoeh.antennapod.util.URLChecker; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringEscapeUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Sends download requests to the DownloadService. This class should always be used for starting downloads, + * otherwise they won't work correctly. + */ +public class DownloadRequester { + private static final String TAG = "DownloadRequester"; + + public static final String IMAGE_DOWNLOADPATH = "images/"; + public static final String FEED_DOWNLOADPATH = "cache/"; + public static final String MEDIA_DOWNLOADPATH = "media/"; + + private static DownloadRequester downloader; + + Map<String, DownloadRequest> downloads; + + private DownloadRequester() { + downloads = new ConcurrentHashMap<String, DownloadRequest>(); + } + + public static synchronized DownloadRequester getInstance() { + if (downloader == null) { + downloader = new DownloadRequester(); + } + return downloader; + } + + /** + * Starts a new download with the given DownloadRequest. This method should only + * be used from outside classes if the DownloadRequest was created by the DownloadService to + * ensure that the data is valid. Use downloadFeed(), downloadImage() or downloadMedia() instead. + * + * @param context Context object for starting the DownloadService + * @param request The DownloadRequest. If another DownloadRequest with the same source URL is already stored, this method + * call will return false. + * @return True if the download request was accepted, false otherwise. + */ + public boolean download(Context context, DownloadRequest request) { + Validate.notNull(context); + Validate.notNull(request); + + if (downloads.containsKey(request.getSource())) { + if (BuildConfig.DEBUG) Log.i(TAG, "DownloadRequest is already stored."); + return false; + } + downloads.put(request.getSource(), request); + + Intent launchIntent = new Intent(context, DownloadService.class); + launchIntent.putExtra(DownloadService.EXTRA_REQUEST, request); + context.startService(launchIntent); + EventDistributor.getInstance().sendDownloadQueuedBroadcast(); + return true; + } + + private void download(Context context, FeedFile item, File dest, + boolean overwriteIfExists, String username, String password, boolean deleteOnFailure) { + if (!isDownloadingFile(item)) { + if (!isFilenameAvailable(dest.toString()) || (deleteOnFailure && dest.exists())) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Filename already used."); + if (isFilenameAvailable(dest.toString()) && overwriteIfExists) { + boolean result = dest.delete(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Deleting file. Result: " + result); + } else { + // find different name + File newDest = null; + for (int i = 1; i < Integer.MAX_VALUE; i++) { + String newName = FilenameUtils.getBaseName(dest + .getName()) + + "-" + + i + + FilenameUtils.EXTENSION_SEPARATOR + + FilenameUtils.getExtension(dest.getName()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Testing filename " + newName); + newDest = new File(dest.getParent(), newName); + if (!newDest.exists() + && isFilenameAvailable(newDest.toString())) { + if (BuildConfig.DEBUG) + Log.d(TAG, "File doesn't exist yet. Using " + + newName); + break; + } + } + if (newDest != null) { + dest = newDest; + } + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, + "Requesting download of url " + item.getDownload_url()); + item.setDownload_url(URLChecker.prepareURL(item.getDownload_url())); + + DownloadRequest request = new DownloadRequest(dest.toString(), + URLChecker.prepareURL(item.getDownload_url()), item.getHumanReadableIdentifier(), + item.getId(), item.getTypeAsInt(), username, password, deleteOnFailure); + + download(context, request); + } else { + Log.e(TAG, "URL " + item.getDownload_url() + + " is already being downloaded"); + } + } + + /** + * Returns true if a filename is available and false if it has already been + * taken by another requested download. + */ + private boolean isFilenameAvailable(String path) { + for (String key : downloads.keySet()) { + DownloadRequest r = downloads.get(key); + if (StringUtils.equals(r.getDestination(), path)) { + if (BuildConfig.DEBUG) + Log.d(TAG, path + + " is already used by another requested download"); + return false; + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, path + " is available as a download destination"); + return true; + } + + public void downloadFeed(Context context, Feed feed) + throws DownloadRequestException { + if (feedFileValid(feed)) { + String username = (feed.getPreferences() != null) ? feed.getPreferences().getUsername() : null; + String password = (feed.getPreferences() != null) ? feed.getPreferences().getPassword() : null; + + download(context, feed, new File(getFeedfilePath(context), + getFeedfileName(feed)), true, username, password, true); + } + } + + public void downloadImage(Context context, FeedImage image) + throws DownloadRequestException { + if (feedFileValid(image)) { + download(context, image, new File(getImagefilePath(context), + getImagefileName(image)), false, null, null, false); + } + } + + public void downloadMedia(Context context, FeedMedia feedmedia) + throws DownloadRequestException { + if (feedFileValid(feedmedia)) { + Feed feed = feedmedia.getItem().getFeed(); + String username; + String password; + if (feed != null && feed.getPreferences() != null) { + username = feed.getPreferences().getUsername(); + password = feed.getPreferences().getPassword(); + } else { + username = null; + password = null; + } + + File dest; + if (feedmedia.getFile_url() != null) { + dest = new File(feedmedia.getFile_url()); + } else { + dest = new File(getMediafilePath(context, feedmedia), + getMediafilename(feedmedia)); + } + download(context, feedmedia, + dest, false, username, password, false + ); + } + } + + /** + * Throws a DownloadRequestException if the feedfile or the download url of + * the feedfile is null. + * + * @throws DownloadRequestException + */ + private boolean feedFileValid(FeedFile f) throws DownloadRequestException { + if (f == null) { + throw new DownloadRequestException("Feedfile was null"); + } else if (f.getDownload_url() == null) { + throw new DownloadRequestException("File has no download URL"); + } else { + return true; + } + } + + /** + * Cancels a running download. + */ + public void cancelDownload(final Context context, final FeedFile f) { + cancelDownload(context, f.getDownload_url()); + } + + /** + * Cancels a running download. + */ + public void cancelDownload(final Context context, final String downloadUrl) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Cancelling download with url " + downloadUrl); + Intent cancelIntent = new Intent(DownloadService.ACTION_CANCEL_DOWNLOAD); + cancelIntent.putExtra(DownloadService.EXTRA_DOWNLOAD_URL, downloadUrl); + context.sendBroadcast(cancelIntent); + } + + /** + * Cancels all running downloads + */ + public void cancelAllDownloads(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Cancelling all running downloads"); + context.sendBroadcast(new Intent( + DownloadService.ACTION_CANCEL_ALL_DOWNLOADS)); + } + + /** + * Returns true if there is at least one Feed in the downloads queue. + */ + public boolean isDownloadingFeeds() { + for (DownloadRequest r : downloads.values()) { + if (r.getFeedfileType() == Feed.FEEDFILETYPE_FEED) { + return true; + } + } + return false; + } + + /** + * Checks if feedfile is in the downloads list + */ + public boolean isDownloadingFile(FeedFile item) { + if (item.getDownload_url() != null) { + return downloads.containsKey(item.getDownload_url()); + } + return false; + } + + public DownloadRequest getDownload(String downloadUrl) { + return downloads.get(downloadUrl); + } + + /** + * Checks if feedfile with the given download url is in the downloads list + */ + public boolean isDownloadingFile(String downloadUrl) { + return downloads.get(downloadUrl) != null; + } + + public boolean hasNoDownloads() { + return downloads.isEmpty(); + } + + /** + * Remove an object from the downloads-list of the requester. + */ + public void removeDownload(DownloadRequest r) { + if (downloads.remove(r.getSource()) == null) { + Log.e(TAG, + "Could not remove object with url " + r.getSource()); + } + } + + /** + * Get the number of uncompleted Downloads + */ + public int getNumberOfDownloads() { + return downloads.size(); + } + + public String getFeedfilePath(Context context) + throws DownloadRequestException { + return getExternalFilesDirOrThrowException(context, FEED_DOWNLOADPATH) + .toString() + "/"; + } + + public String getFeedfileName(Feed feed) { + String filename = feed.getDownload_url(); + if (feed.getTitle() != null && !feed.getTitle().isEmpty()) { + filename = feed.getTitle(); + } + return "feed-" + FileNameGenerator.generateFileName(filename); + } + + public String getImagefilePath(Context context) + throws DownloadRequestException { + return getExternalFilesDirOrThrowException(context, IMAGE_DOWNLOADPATH) + .toString() + "/"; + } + + public String getImagefileName(FeedImage image) { + String filename = image.getDownload_url(); + if (image.getOwner() != null && image.getOwner().getHumanReadableIdentifier() != null) { + filename = image.getOwner().getHumanReadableIdentifier(); + } + return "image-" + FileNameGenerator.generateFileName(filename); + } + + public String getMediafilePath(Context context, FeedMedia media) + throws DownloadRequestException { + File externalStorage = getExternalFilesDirOrThrowException( + context, + MEDIA_DOWNLOADPATH + + FileNameGenerator.generateFileName(media.getItem() + .getFeed().getTitle()) + "/" + ); + return externalStorage.toString(); + } + + private File getExternalFilesDirOrThrowException(Context context, + String type) throws DownloadRequestException { + File result = UserPreferences.getDataFolder(context, type); + if (result == null) { + throw new DownloadRequestException( + "Failed to access external storage"); + } + return result; + } + + public String getMediafilename(FeedMedia media) { + String filename; + String titleBaseFilename = ""; + + // Try to generate the filename by the item title + if (media.getItem() != null && media.getItem().getTitle() != null) { + String title = media.getItem().getTitle(); + // Delete reserved characters + titleBaseFilename = title.replaceAll("[\\\\/%\\?\\*:|<>\"\\p{Cntrl}]", ""); + titleBaseFilename = titleBaseFilename.trim(); + } + + String URLBaseFilename = URLUtil.guessFileName(media.getDownload_url(), + null, media.getMime_type()); + ; + + if (titleBaseFilename != "") { + // Append extension + filename = titleBaseFilename + FilenameUtils.EXTENSION_SEPARATOR + + FilenameUtils.getExtension(URLBaseFilename); + } else { + // Fall back on URL file name + filename = URLBaseFilename; + } + return filename; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java b/app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java new file mode 100644 index 000000000..8cb040756 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java @@ -0,0 +1,70 @@ +package de.danoeh.antennapod.storage; + +import java.util.Date; + +/** + * Contains information about a feed's items. + */ +public class FeedItemStatistics { + private long feedID; + private int numberOfItems; + private int numberOfNewItems; + private int numberOfInProgressItems; + private Date lastUpdate; + private static final Date UNKNOWN_DATE = new Date(0); + + + /** + * Creates new FeedItemStatistics object. + * + * @param feedID ID of the feed. + * @param numberOfItems Number of items that this feed has. + * @param numberOfNewItems Number of unread items this feed has. + * @param numberOfInProgressItems Number of items that the user has started listening to. + * @param lastUpdate pubDate of the latest episode. A lastUpdate value of 0 will be interpreted as DATE_UNKOWN if + * numberOfItems is 0. + */ + public FeedItemStatistics(long feedID, int numberOfItems, int numberOfNewItems, int numberOfInProgressItems, Date lastUpdate) { + this.feedID = feedID; + this.numberOfItems = numberOfItems; + this.numberOfNewItems = numberOfNewItems; + this.numberOfInProgressItems = numberOfInProgressItems; + if (numberOfItems > 0) { + this.lastUpdate = (lastUpdate != null) ? (Date) lastUpdate.clone() : null; + } else { + this.lastUpdate = UNKNOWN_DATE; + } + } + + public long getFeedID() { + return feedID; + } + + public int getNumberOfItems() { + return numberOfItems; + } + + public int getNumberOfNewItems() { + return numberOfNewItems; + } + + public int getNumberOfInProgressItems() { + return numberOfInProgressItems; + } + + /** + * Returns the pubDate of the latest item in the feed. Users of this method + * should check if this value is unkown or not by calling lastUpdateKnown() first. + */ + public Date getLastUpdate() { + return (lastUpdate != null) ? (Date) lastUpdate.clone() : null; + } + + /** + * Returns true if the lastUpdate value is known. The lastUpdate value is unkown if the + * feed has no items. + */ + public boolean lastUpdateKnown() { + return lastUpdate != UNKNOWN_DATE; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java b/app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java new file mode 100644 index 000000000..e7aa93f83 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java @@ -0,0 +1,57 @@ +package de.danoeh.antennapod.storage; + +import android.content.Context; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.SearchResult; +import de.danoeh.antennapod.util.comparator.SearchResultValueComparator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** + * Performs search on Feeds and FeedItems + */ +public class FeedSearcher { + private static final String TAG = "FeedSearcher"; + + + /** + * Performs a search in all feeds or one specific feed. + */ + public static List<SearchResult> performSearch(final Context context, + final String query, final long selectedFeed) { + final int values[] = {0, 0, 1, 2}; + final String[] subtitles = {context.getString(R.string.found_in_shownotes_label), + context.getString(R.string.found_in_shownotes_label), + context.getString(R.string.found_in_chapters_label), + context.getString(R.string.found_in_title_label)}; + + List<SearchResult> result = new ArrayList<SearchResult>(); + + FutureTask<List<FeedItem>>[] tasks = new FutureTask[4]; + (tasks[0] = DBTasks.searchFeedItemContentEncoded(context, selectedFeed, query)).run(); + (tasks[1] = DBTasks.searchFeedItemDescription(context, selectedFeed, query)).run(); + (tasks[2] = DBTasks.searchFeedItemChapters(context, selectedFeed, query)).run(); + (tasks[3] = DBTasks.searchFeedItemTitle(context, selectedFeed, query)).run(); + try { + for (int i = 0; i < tasks.length; i++) { + FutureTask task = tasks[i]; + List<FeedItem> items = (List<FeedItem>) task.get(); + for (FeedItem item : items) { + result.add(new SearchResult(item, values[i], subtitles[i])); + } + + } + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + Collections.sort(result, new SearchResultValueComparator()); + return result; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java b/app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java new file mode 100644 index 000000000..671ac30d5 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java @@ -0,0 +1,1391 @@ +package de.danoeh.antennapod.storage; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.MergeCursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDatabase.CursorFactory; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import java.util.Arrays; +import java.util.List; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedComponent; +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.feed.FeedPreferences; +import de.danoeh.antennapod.service.download.DownloadStatus; +import de.danoeh.antennapod.util.flattr.FlattrStatus; + +// TODO Remove media column from feeditem table + +/** + * Implements methods for accessing the database + */ +public class PodDBAdapter { + private static final String TAG = "PodDBAdapter"; + private static final int DATABASE_VERSION = 12; + public static final String DATABASE_NAME = "Antennapod.db"; + + /** + * Maximum number of arguments for IN-operator. + */ + public static final int IN_OPERATOR_MAXIMUM = 800; + + /** + * Maximum number of entries per search request. + */ + public static final int SEARCH_LIMIT = 30; + + // ----------- Column indices + // ----------- General indices + public static final int KEY_ID_INDEX = 0; + public static final int KEY_TITLE_INDEX = 1; + public static final int KEY_FILE_URL_INDEX = 2; + public static final int KEY_DOWNLOAD_URL_INDEX = 3; + public static final int KEY_DOWNLOADED_INDEX = 4; + public static final int KEY_LINK_INDEX = 5; + public static final int KEY_DESCRIPTION_INDEX = 6; + public static final int KEY_PAYMENT_LINK_INDEX = 7; + // ----------- Feed indices + public static final int KEY_LAST_UPDATE_INDEX = 8; + public static final int KEY_LANGUAGE_INDEX = 9; + public static final int KEY_AUTHOR_INDEX = 10; + public static final int KEY_IMAGE_INDEX = 11; + public static final int KEY_TYPE_INDEX = 12; + public static final int KEY_FEED_IDENTIFIER_INDEX = 13; + public static final int KEY_FEED_FLATTR_STATUS_INDEX = 14; + public static final int KEY_FEED_USERNAME_INDEX = 15; + public static final int KEY_FEED_PASSWORD_INDEX = 16; + // ----------- FeedItem indices + public static final int KEY_CONTENT_ENCODED_INDEX = 2; + public static final int KEY_PUBDATE_INDEX = 3; + public static final int KEY_READ_INDEX = 4; + public static final int KEY_MEDIA_INDEX = 8; + public static final int KEY_FEED_INDEX = 9; + public static final int KEY_HAS_SIMPLECHAPTERS_INDEX = 10; + public static final int KEY_ITEM_IDENTIFIER_INDEX = 11; + public static final int KEY_ITEM_FLATTR_STATUS_INDEX = 12; + // ---------- FeedMedia indices + public static final int KEY_DURATION_INDEX = 1; + public static final int KEY_POSITION_INDEX = 5; + public static final int KEY_SIZE_INDEX = 6; + public static final int KEY_MIME_TYPE_INDEX = 7; + public static final int KEY_PLAYBACK_COMPLETION_DATE_INDEX = 8; + public static final int KEY_MEDIA_FEEDITEM_INDEX = 9; + public static final int KEY_PLAYED_DURATION_INDEX = 10; + // --------- Download log indices + public static final int KEY_FEEDFILE_INDEX = 1; + public static final int KEY_FEEDFILETYPE_INDEX = 2; + public static final int KEY_REASON_INDEX = 3; + public static final int KEY_SUCCESSFUL_INDEX = 4; + public static final int KEY_COMPLETION_DATE_INDEX = 5; + public static final int KEY_REASON_DETAILED_INDEX = 6; + public static final int KEY_DOWNLOADSTATUS_TITLE_INDEX = 7; + // --------- Queue indices + public static final int KEY_FEEDITEM_INDEX = 1; + public static final int KEY_QUEUE_FEED_INDEX = 2; + // --------- Chapters indices + public static final int KEY_CHAPTER_START_INDEX = 2; + public static final int KEY_CHAPTER_FEEDITEM_INDEX = 3; + public static final int KEY_CHAPTER_LINK_INDEX = 4; + public static final int KEY_CHAPTER_TYPE_INDEX = 5; + + // Key-constants + public static final String KEY_ID = "id"; + public static final String KEY_TITLE = "title"; + public static final String KEY_NAME = "name"; + public static final String KEY_LINK = "link"; + public static final String KEY_DESCRIPTION = "description"; + public static final String KEY_FILE_URL = "file_url"; + public static final String KEY_DOWNLOAD_URL = "download_url"; + public static final String KEY_PUBDATE = "pubDate"; + public static final String KEY_READ = "read"; + public static final String KEY_DURATION = "duration"; + public static final String KEY_POSITION = "position"; + public static final String KEY_SIZE = "filesize"; + public static final String KEY_MIME_TYPE = "mime_type"; + public static final String KEY_IMAGE = "image"; + public static final String KEY_FEED = "feed"; + public static final String KEY_MEDIA = "media"; + public static final String KEY_DOWNLOADED = "downloaded"; + public static final String KEY_LASTUPDATE = "last_update"; + public static final String KEY_FEEDFILE = "feedfile"; + public static final String KEY_REASON = "reason"; + public static final String KEY_SUCCESSFUL = "successful"; + public static final String KEY_FEEDFILETYPE = "feedfile_type"; + public static final String KEY_COMPLETION_DATE = "completion_date"; + public static final String KEY_FEEDITEM = "feeditem"; + public static final String KEY_CONTENT_ENCODED = "content_encoded"; + public static final String KEY_PAYMENT_LINK = "payment_link"; + public static final String KEY_START = "start"; + public static final String KEY_LANGUAGE = "language"; + public static final String KEY_AUTHOR = "author"; + public static final String KEY_HAS_CHAPTERS = "has_simple_chapters"; + public static final String KEY_TYPE = "type"; + public static final String KEY_ITEM_IDENTIFIER = "item_identifier"; + public static final String KEY_FLATTR_STATUS = "flattr_status"; + public static final String KEY_FEED_IDENTIFIER = "feed_identifier"; + public static final String KEY_REASON_DETAILED = "reason_detailed"; + public static final String KEY_DOWNLOADSTATUS_TITLE = "title"; + public static final String KEY_CHAPTER_TYPE = "type"; + public static final String KEY_PLAYBACK_COMPLETION_DATE = "playback_completion_date"; + public static final String KEY_AUTO_DOWNLOAD = "auto_download"; + public static final String KEY_PLAYED_DURATION = "played_duration"; + public static final String KEY_USERNAME = "username"; + public static final String KEY_PASSWORD = "password"; + + // Table names + public static final String TABLE_NAME_FEEDS = "Feeds"; + public static final String TABLE_NAME_FEED_ITEMS = "FeedItems"; + public static final String TABLE_NAME_FEED_IMAGES = "FeedImages"; + public static final String TABLE_NAME_FEED_MEDIA = "FeedMedia"; + public static final String TABLE_NAME_DOWNLOAD_LOG = "DownloadLog"; + public static final String TABLE_NAME_QUEUE = "Queue"; + public static final String TABLE_NAME_SIMPLECHAPTERS = "SimpleChapters"; + + // SQL Statements for creating new tables + private static final String TABLE_PRIMARY_KEY = KEY_ID + + " INTEGER PRIMARY KEY AUTOINCREMENT ,"; + + private static final String CREATE_TABLE_FEEDS = "CREATE TABLE " + + TABLE_NAME_FEEDS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE + + " TEXT," + KEY_FILE_URL + " TEXT," + KEY_DOWNLOAD_URL + " TEXT," + + KEY_DOWNLOADED + " INTEGER," + KEY_LINK + " TEXT," + + KEY_DESCRIPTION + " TEXT," + KEY_PAYMENT_LINK + " TEXT," + + KEY_LASTUPDATE + " TEXT," + KEY_LANGUAGE + " TEXT," + KEY_AUTHOR + + " TEXT," + KEY_IMAGE + " INTEGER," + KEY_TYPE + " TEXT," + + KEY_FEED_IDENTIFIER + " TEXT," + KEY_AUTO_DOWNLOAD + " INTEGER DEFAULT 1," + + KEY_FLATTR_STATUS + " INTEGER," + + KEY_USERNAME + " TEXT," + + KEY_PASSWORD + " TEXT)"; + + private static final String CREATE_TABLE_FEED_ITEMS = "CREATE TABLE " + + TABLE_NAME_FEED_ITEMS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE + + " TEXT," + KEY_CONTENT_ENCODED + " TEXT," + KEY_PUBDATE + + " INTEGER," + KEY_READ + " INTEGER," + KEY_LINK + " TEXT," + + KEY_DESCRIPTION + " TEXT," + KEY_PAYMENT_LINK + " TEXT," + + KEY_MEDIA + " INTEGER," + KEY_FEED + " INTEGER," + + KEY_HAS_CHAPTERS + " INTEGER," + KEY_ITEM_IDENTIFIER + " TEXT," + + KEY_FLATTR_STATUS + " INTEGER," + + KEY_IMAGE + " INTEGER)"; + + private static final String CREATE_TABLE_FEED_IMAGES = "CREATE TABLE " + + TABLE_NAME_FEED_IMAGES + " (" + TABLE_PRIMARY_KEY + KEY_TITLE + + " TEXT," + KEY_FILE_URL + " TEXT," + KEY_DOWNLOAD_URL + " TEXT," + + KEY_DOWNLOADED + " INTEGER)"; + + private static final String CREATE_TABLE_FEED_MEDIA = "CREATE TABLE " + + TABLE_NAME_FEED_MEDIA + " (" + TABLE_PRIMARY_KEY + KEY_DURATION + + " INTEGER," + KEY_FILE_URL + " TEXT," + KEY_DOWNLOAD_URL + + " TEXT," + KEY_DOWNLOADED + " INTEGER," + KEY_POSITION + + " INTEGER," + KEY_SIZE + " INTEGER," + KEY_MIME_TYPE + " TEXT," + + KEY_PLAYBACK_COMPLETION_DATE + " INTEGER," + + KEY_FEEDITEM + " INTEGER," + + KEY_PLAYED_DURATION + " INTEGER)"; + + private static final String CREATE_TABLE_DOWNLOAD_LOG = "CREATE TABLE " + + TABLE_NAME_DOWNLOAD_LOG + " (" + TABLE_PRIMARY_KEY + KEY_FEEDFILE + + " INTEGER," + KEY_FEEDFILETYPE + " INTEGER," + KEY_REASON + + " INTEGER," + KEY_SUCCESSFUL + " INTEGER," + KEY_COMPLETION_DATE + + " INTEGER," + KEY_REASON_DETAILED + " TEXT," + + KEY_DOWNLOADSTATUS_TITLE + " TEXT)"; + + private static final String CREATE_TABLE_QUEUE = "CREATE TABLE " + + TABLE_NAME_QUEUE + "(" + KEY_ID + " INTEGER PRIMARY KEY," + + KEY_FEEDITEM + " INTEGER," + KEY_FEED + " INTEGER)"; + + private static final String CREATE_TABLE_SIMPLECHAPTERS = "CREATE TABLE " + + TABLE_NAME_SIMPLECHAPTERS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE + + " TEXT," + KEY_START + " INTEGER," + KEY_FEEDITEM + " INTEGER," + + KEY_LINK + " TEXT," + KEY_CHAPTER_TYPE + " INTEGER)"; + + private SQLiteDatabase db; + private final Context context; + private PodDBHelper helper; + + /** + * Select all columns from the feed-table + */ + private static final String[] FEED_SEL_STD = { + TABLE_NAME_FEEDS + "." + KEY_ID, + TABLE_NAME_FEEDS + "." + KEY_TITLE, + TABLE_NAME_FEEDS + "." + KEY_FILE_URL, + TABLE_NAME_FEEDS + "." + KEY_DOWNLOAD_URL, + TABLE_NAME_FEEDS + "." + KEY_DOWNLOADED, + TABLE_NAME_FEEDS + "." + KEY_LINK, + TABLE_NAME_FEEDS + "." + KEY_DESCRIPTION, + TABLE_NAME_FEEDS + "." + KEY_PAYMENT_LINK, + TABLE_NAME_FEEDS + "." + KEY_LASTUPDATE, + TABLE_NAME_FEEDS + "." + KEY_LANGUAGE, + TABLE_NAME_FEEDS + "." + KEY_AUTHOR, + TABLE_NAME_FEEDS + "." + KEY_IMAGE, + TABLE_NAME_FEEDS + "." + KEY_TYPE, + TABLE_NAME_FEEDS + "." + KEY_FEED_IDENTIFIER, + TABLE_NAME_FEEDS + "." + KEY_AUTO_DOWNLOAD, + TABLE_NAME_FEEDS + "." + KEY_FLATTR_STATUS, + TABLE_NAME_FEEDS + "." + KEY_USERNAME, + TABLE_NAME_FEEDS + "." + KEY_PASSWORD + }; + + // column indices for FEED_SEL_STD + public static final int IDX_FEED_SEL_STD_ID = 0; + public static final int IDX_FEED_SEL_STD_TITLE = 1; + public static final int IDX_FEED_SEL_STD_FILE_URL = 2; + public static final int IDX_FEED_SEL_STD_DOWNLOAD_URL = 3; + public static final int IDX_FEED_SEL_STD_DOWNLOADED = 4; + public static final int IDX_FEED_SEL_STD_LINK = 5; + public static final int IDX_FEED_SEL_STD_DESCRIPTION = 6; + public static final int IDX_FEED_SEL_STD_PAYMENT_LINK = 7; + public static final int IDX_FEED_SEL_STD_LASTUPDATE = 8; + public static final int IDX_FEED_SEL_STD_LANGUAGE = 9; + public static final int IDX_FEED_SEL_STD_AUTHOR = 10; + public static final int IDX_FEED_SEL_STD_IMAGE = 11; + public static final int IDX_FEED_SEL_STD_TYPE = 12; + public static final int IDX_FEED_SEL_STD_FEED_IDENTIFIER = 13; + public static final int IDX_FEED_SEL_PREFERENCES_AUTO_DOWNLOAD = 14; + public static final int IDX_FEED_SEL_STD_FLATTR_STATUS = 15; + public static final int IDX_FEED_SEL_PREFERENCES_USERNAME = 16; + public static final int IDX_FEED_SEL_PREFERENCES_PASSWORD = 17; + + + /** + * Select all columns from the feeditems-table except description and + * content-encoded. + */ + private static final String[] FEEDITEM_SEL_FI_SMALL = { + TABLE_NAME_FEED_ITEMS + "." + KEY_ID, + TABLE_NAME_FEED_ITEMS + "." + KEY_TITLE, + TABLE_NAME_FEED_ITEMS + "." + KEY_PUBDATE, + TABLE_NAME_FEED_ITEMS + "." + KEY_READ, + TABLE_NAME_FEED_ITEMS + "." + KEY_LINK, + TABLE_NAME_FEED_ITEMS + "." + KEY_PAYMENT_LINK, KEY_MEDIA, + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED, + TABLE_NAME_FEED_ITEMS + "." + KEY_HAS_CHAPTERS, + TABLE_NAME_FEED_ITEMS + "." + KEY_ITEM_IDENTIFIER, + TABLE_NAME_FEED_ITEMS + "." + KEY_FLATTR_STATUS, + TABLE_NAME_FEED_ITEMS + "." + KEY_IMAGE}; + + /** + * Contains FEEDITEM_SEL_FI_SMALL as comma-separated list. Useful for raw queries. + */ + private static final String SEL_FI_SMALL_STR; + + static { + String selFiSmall = Arrays.toString(FEEDITEM_SEL_FI_SMALL); + SEL_FI_SMALL_STR = selFiSmall.substring(1, selFiSmall.length() - 1); + } + + // column indices for FEEDITEM_SEL_FI_SMALL + + public static final int IDX_FI_SMALL_ID = 0; + public static final int IDX_FI_SMALL_TITLE = 1; + public static final int IDX_FI_SMALL_PUBDATE = 2; + public static final int IDX_FI_SMALL_READ = 3; + public static final int IDX_FI_SMALL_LINK = 4; + public static final int IDX_FI_SMALL_PAYMENT_LINK = 5; + public static final int IDX_FI_SMALL_MEDIA = 6; + public static final int IDX_FI_SMALL_FEED = 7; + public static final int IDX_FI_SMALL_HAS_CHAPTERS = 8; + public static final int IDX_FI_SMALL_ITEM_IDENTIFIER = 9; + public static final int IDX_FI_SMALL_FLATTR_STATUS = 10; + public static final int IDX_FI_SMALL_IMAGE = 11; + + /** + * Select id, description and content-encoded column from feeditems. + */ + private static final String[] SEL_FI_EXTRA = {KEY_ID, KEY_DESCRIPTION, + KEY_CONTENT_ENCODED, KEY_FEED}; + + // column indices for SEL_FI_EXTRA + + public static final int IDX_FI_EXTRA_ID = 0; + public static final int IDX_FI_EXTRA_DESCRIPTION = 1; + public static final int IDX_FI_EXTRA_CONTENT_ENCODED = 2; + public static final int IDX_FI_EXTRA_FEED = 3; + + static PodDBHelper dbHelperSingleton; + + private static synchronized PodDBHelper getDbHelperSingleton(Context appContext) { + if (dbHelperSingleton == null) { + dbHelperSingleton = new PodDBHelper(appContext, DATABASE_NAME, null, DATABASE_VERSION); + } + return dbHelperSingleton; + } + + public PodDBAdapter(Context c) { + this.context = c; + helper = getDbHelperSingleton(c.getApplicationContext()); + } + + public PodDBAdapter open() { + if (db == null || !db.isOpen() || db.isReadOnly()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Opening DB"); + try { + db = helper.getWritableDatabase(); + } catch (SQLException ex) { + ex.printStackTrace(); + db = helper.getReadableDatabase(); + } + } + return this; + } + + public void close() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Closing DB"); + //db.close(); + } + + public static boolean deleteDatabase(Context context) { + Log.w(TAG, "Deleting database"); + dbHelperSingleton.close(); + dbHelperSingleton = null; + return context.deleteDatabase(DATABASE_NAME); + } + + /** + * Inserts or updates a feed entry + * + * @return the id of the entry + */ + public long setFeed(Feed feed) { + ContentValues values = new ContentValues(); + values.put(KEY_TITLE, feed.getTitle()); + values.put(KEY_LINK, feed.getLink()); + values.put(KEY_DESCRIPTION, feed.getDescription()); + values.put(KEY_PAYMENT_LINK, feed.getPaymentLink()); + values.put(KEY_AUTHOR, feed.getAuthor()); + values.put(KEY_LANGUAGE, feed.getLanguage()); + if (feed.getImage() != null) { + if (feed.getImage().getId() == 0) { + setImage(feed.getImage()); + } + values.put(KEY_IMAGE, feed.getImage().getId()); + } + + values.put(KEY_FILE_URL, feed.getFile_url()); + values.put(KEY_DOWNLOAD_URL, feed.getDownload_url()); + values.put(KEY_DOWNLOADED, feed.isDownloaded()); + values.put(KEY_LASTUPDATE, feed.getLastUpdate().getTime()); + values.put(KEY_TYPE, feed.getType()); + values.put(KEY_FEED_IDENTIFIER, feed.getFeedIdentifier()); + + Log.d(TAG, "Setting feed with flattr status " + feed.getTitle() + ": " + feed.getFlattrStatus().toLong()); + + values.put(KEY_FLATTR_STATUS, feed.getFlattrStatus().toLong()); + if (feed.getId() == 0) { + // Create new entry + if (BuildConfig.DEBUG) + Log.d(this.toString(), "Inserting new Feed into db"); + feed.setId(db.insert(TABLE_NAME_FEEDS, null, values)); + } else { + if (BuildConfig.DEBUG) + Log.d(this.toString(), "Updating existing Feed in db"); + db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", + new String[]{String.valueOf(feed.getId())}); + + } + return feed.getId(); + } + + public void setFeedPreferences(FeedPreferences prefs) { + if (prefs.getFeedID() == 0) { + throw new IllegalArgumentException("Feed ID of preference must not be null"); + } + ContentValues values = new ContentValues(); + values.put(KEY_AUTO_DOWNLOAD, prefs.getAutoDownload()); + values.put(KEY_USERNAME, prefs.getUsername()); + values.put(KEY_PASSWORD, prefs.getPassword()); + db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(prefs.getFeedID())}); + } + + /** + * Inserts or updates an image entry + * + * @return the id of the entry + */ + public long setImage(FeedImage image) { + db.beginTransaction(); + ContentValues values = new ContentValues(); + values.put(KEY_TITLE, image.getTitle()); + values.put(KEY_DOWNLOAD_URL, image.getDownload_url()); + values.put(KEY_DOWNLOADED, image.isDownloaded()); + values.put(KEY_FILE_URL, image.getFile_url()); + if (image.getId() == 0) { + image.setId(db.insert(TABLE_NAME_FEED_IMAGES, null, values)); + } else { + db.update(TABLE_NAME_FEED_IMAGES, values, KEY_ID + "=?", + new String[]{String.valueOf(image.getId())}); + } + + final FeedComponent owner = image.getOwner(); + if (owner != null && owner.getId() != 0) { + values.clear(); + values.put(KEY_IMAGE, image.getId()); + if (owner instanceof Feed) { + db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(image.getOwner().getId())}); + } + } + db.setTransactionSuccessful(); + db.endTransaction(); + return image.getId(); + } + + /** + * Inserts or updates an image entry + * + * @return the id of the entry + */ + public long setMedia(FeedMedia media) { + ContentValues values = new ContentValues(); + values.put(KEY_DURATION, media.getDuration()); + values.put(KEY_POSITION, media.getPosition()); + values.put(KEY_SIZE, media.getSize()); + values.put(KEY_MIME_TYPE, media.getMime_type()); + values.put(KEY_DOWNLOAD_URL, media.getDownload_url()); + values.put(KEY_DOWNLOADED, media.isDownloaded()); + values.put(KEY_FILE_URL, media.getFile_url()); + + if (media.getPlaybackCompletionDate() != null) { + values.put(KEY_PLAYBACK_COMPLETION_DATE, media + .getPlaybackCompletionDate().getTime()); + } else { + values.put(KEY_PLAYBACK_COMPLETION_DATE, 0); + } + if (media.getItem() != null) { + values.put(KEY_FEEDITEM, media.getItem().getId()); + } + if (media.getId() == 0) { + media.setId(db.insert(TABLE_NAME_FEED_MEDIA, null, values)); + } else { + db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?", + new String[]{String.valueOf(media.getId())}); + } + return media.getId(); + } + + public void setFeedMediaPlaybackInformation(FeedMedia media) { + if (media.getId() != 0) { + ContentValues values = new ContentValues(); + values.put(KEY_POSITION, media.getPosition()); + values.put(KEY_DURATION, media.getDuration()); + values.put(KEY_PLAYED_DURATION, media.getPlayedDuration()); + db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?", + new String[]{String.valueOf(media.getId())}); + } else { + Log.e(TAG, "setFeedMediaPlaybackInformation: ID of media was 0"); + } + } + + public void setFeedMediaPlaybackCompletionDate(FeedMedia media) { + if (media.getId() != 0) { + ContentValues values = new ContentValues(); + values.put(KEY_PLAYBACK_COMPLETION_DATE, media.getPlaybackCompletionDate().getTime()); + values.put(KEY_PLAYED_DURATION, media.getPlayedDuration()); + db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?", + new String[]{String.valueOf(media.getId())}); + } else { + Log.e(TAG, "setFeedMediaPlaybackCompletionDate: ID of media was 0"); + } + } + + /** + * Insert all FeedItems of a feed and the feed object itself in a single + * transaction + */ + public void setCompleteFeed(Feed... feeds) { + db.beginTransaction(); + for (Feed feed : feeds) { + setFeed(feed); + if (feed.getItems() != null) { + for (FeedItem item : feed.getItems()) { + setFeedItem(item, false); + } + } + if (feed.getPreferences() != null) { + setFeedPreferences(feed.getPreferences()); + } + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + /** + * Update the flattr status of a feed + */ + public void setFeedFlattrStatus(Feed feed) { + ContentValues values = new ContentValues(); + values.put(KEY_FLATTR_STATUS, feed.getFlattrStatus().toLong()); + db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(feed.getId())}); + } + + /** + * Get all feeds in the flattr queue. + */ + public Cursor getFeedsInFlattrQueueCursor() { + return db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, KEY_FLATTR_STATUS + "=?", + new String[]{String.valueOf(FlattrStatus.STATUS_QUEUE)}, null, null, null); + } + + /** + * Get all feed items in the flattr queue. + */ + public Cursor getFeedItemsInFlattrQueueCursor() { + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FLATTR_STATUS + "=?", + new String[]{String.valueOf(FlattrStatus.STATUS_QUEUE)}, null, null, null); + } + + /** + * Counts feeds and feed items in the flattr queue + */ + public int getFlattrQueueSize() { + int res = 0; + Cursor c = db.rawQuery(String.format("SELECT count(*) FROM %s WHERE %s=%s", + TABLE_NAME_FEEDS, KEY_FLATTR_STATUS, String.valueOf(FlattrStatus.STATUS_QUEUE)), null); + if (c.moveToFirst()) { + res = c.getInt(0); + c.close(); + } else { + Log.e(TAG, "Unable to determine size of flattr queue: Could not count number of feeds"); + } + c = db.rawQuery(String.format("SELECT count(*) FROM %s WHERE %s=%s", + TABLE_NAME_FEED_ITEMS, KEY_FLATTR_STATUS, String.valueOf(FlattrStatus.STATUS_QUEUE)), null); + if (c.moveToFirst()) { + res += c.getInt(0); + c.close(); + } else { + Log.e(TAG, "Unable to determine size of flattr queue: Could not count number of feed items"); + } + + return res; + } + + /** + * Updates the download URL of a Feed. + */ + public void setFeedDownloadUrl(String original, String updated) { + ContentValues values = new ContentValues(); + values.put(KEY_DOWNLOAD_URL, updated); + db.update(TABLE_NAME_FEEDS, values, KEY_DOWNLOAD_URL + "=?", new String[]{original}); + } + + public void setFeedItemlist(List<FeedItem> items) { + db.beginTransaction(); + for (FeedItem item : items) { + setFeedItem(item, true); + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + public long setSingleFeedItem(FeedItem item) { + db.beginTransaction(); + long result = setFeedItem(item, true); + db.setTransactionSuccessful(); + db.endTransaction(); + return result; + } + + /** + * Update the flattr status of a FeedItem + */ + public void setFeedItemFlattrStatus(FeedItem feedItem) { + ContentValues values = new ContentValues(); + values.put(KEY_FLATTR_STATUS, feedItem.getFlattrStatus().toLong()); + db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", new String[]{String.valueOf(feedItem.getId())}); + } + + /** + * Update the flattr status of a feed or feed item specified by its payment link + * and the new flattr status to use + */ + public void setItemFlattrStatus(String url, FlattrStatus status) { + //Log.d(TAG, "setItemFlattrStatus(" + url + ") = " + status.toString()); + ContentValues values = new ContentValues(); + values.put(KEY_FLATTR_STATUS, status.toLong()); + + // regexps in sqlite would be neat! + String[] query_urls = new String[]{ + "*" + url + "&*", + "*" + url + "%2F&*", + "*" + url + "", + "*" + url + "%2F" + }; + + if (db.update(TABLE_NAME_FEEDS, values, + KEY_PAYMENT_LINK + " GLOB ?" + + " OR " + KEY_PAYMENT_LINK + " GLOB ?" + + " OR " + KEY_PAYMENT_LINK + " GLOB ?" + + " OR " + KEY_PAYMENT_LINK + " GLOB ?", query_urls + ) > 0) { + Log.i(TAG, "setItemFlattrStatus found match for " + url + " = " + status.toLong() + " in Feeds table"); + return; + } + if (db.update(TABLE_NAME_FEED_ITEMS, values, + KEY_PAYMENT_LINK + " GLOB ?" + + " OR " + KEY_PAYMENT_LINK + " GLOB ?" + + " OR " + KEY_PAYMENT_LINK + " GLOB ?" + + " OR " + KEY_PAYMENT_LINK + " GLOB ?", query_urls + ) > 0) { + Log.i(TAG, "setItemFlattrStatus found match for " + url + " = " + status.toLong() + " in FeedsItems table"); + } + } + + /** + * Reset flattr status to unflattrd for all items + */ + public void clearAllFlattrStatus() { + ContentValues values = new ContentValues(); + values.put(KEY_FLATTR_STATUS, 0); + db.update(TABLE_NAME_FEEDS, values, null, null); + db.update(TABLE_NAME_FEED_ITEMS, values, null, null); + } + + /** + * Inserts or updates a feeditem entry + * + * @param item The FeedItem + * @param saveFeed true if the Feed of the item should also be saved. This should be set to + * false if the method is executed on a list of FeedItems of the same Feed. + * @return the id of the entry + */ + private long setFeedItem(FeedItem item, boolean saveFeed) { + ContentValues values = new ContentValues(); + values.put(KEY_TITLE, item.getTitle()); + values.put(KEY_LINK, item.getLink()); + if (item.getDescription() != null) { + values.put(KEY_DESCRIPTION, item.getDescription()); + } + if (item.getContentEncoded() != null) { + values.put(KEY_CONTENT_ENCODED, item.getContentEncoded()); + } + values.put(KEY_PUBDATE, item.getPubDate().getTime()); + values.put(KEY_PAYMENT_LINK, item.getPaymentLink()); + if (saveFeed && item.getFeed() != null) { + setFeed(item.getFeed()); + } + values.put(KEY_FEED, item.getFeed().getId()); + values.put(KEY_READ, item.isRead()); + values.put(KEY_HAS_CHAPTERS, item.getChapters() != null); + values.put(KEY_ITEM_IDENTIFIER, item.getItemIdentifier()); + values.put(KEY_FLATTR_STATUS, item.getFlattrStatus().toLong()); + if (item.hasItemImage()) { + if (item.getImage().getId() == 0) { + setImage(item.getImage()); + } + values.put(KEY_IMAGE, item.getImage().getId()); + } + + if (item.getId() == 0) { + item.setId(db.insert(TABLE_NAME_FEED_ITEMS, null, values)); + } else { + db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", + new String[]{String.valueOf(item.getId())}); + } + if (item.getMedia() != null) { + setMedia(item.getMedia()); + } + if (item.getChapters() != null) { + setChapters(item); + } + return item.getId(); + } + + public void setFeedItemRead(boolean read, long itemId, long mediaId, + boolean resetMediaPosition) { + db.beginTransaction(); + ContentValues values = new ContentValues(); + + values.put(KEY_READ, read); + db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", new String[]{String.valueOf(itemId)}); + + if (resetMediaPosition) { + values.clear(); + values.put(KEY_POSITION, 0); + db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?", new String[]{String.valueOf(mediaId)}); + } + + db.setTransactionSuccessful(); + db.endTransaction(); + } + + public void setFeedItemRead(boolean read, long... itemIds) { + db.beginTransaction(); + ContentValues values = new ContentValues(); + for (long id : itemIds) { + values.clear(); + values.put(KEY_READ, read); + db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", new String[]{String.valueOf(id)}); + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + public void setChapters(FeedItem item) { + ContentValues values = new ContentValues(); + for (Chapter chapter : item.getChapters()) { + values.put(KEY_TITLE, chapter.getTitle()); + values.put(KEY_START, chapter.getStart()); + values.put(KEY_FEEDITEM, item.getId()); + values.put(KEY_LINK, chapter.getLink()); + values.put(KEY_CHAPTER_TYPE, chapter.getChapterType()); + if (chapter.getId() == 0) { + chapter.setId(db + .insert(TABLE_NAME_SIMPLECHAPTERS, null, values)); + } else { + db.update(TABLE_NAME_SIMPLECHAPTERS, values, KEY_ID + "=?", + new String[]{String.valueOf(chapter.getId())}); + } + } + } + + /** + * Inserts or updates a download status. + */ + public long setDownloadStatus(DownloadStatus status) { + ContentValues values = new ContentValues(); + values.put(KEY_FEEDFILE, status.getFeedfileId()); + values.put(KEY_FEEDFILETYPE, status.getFeedfileType()); + values.put(KEY_REASON, status.getReason().getCode()); + values.put(KEY_SUCCESSFUL, status.isSuccessful()); + values.put(KEY_COMPLETION_DATE, status.getCompletionDate().getTime()); + values.put(KEY_REASON_DETAILED, status.getReasonDetailed()); + values.put(KEY_DOWNLOADSTATUS_TITLE, status.getTitle()); + if (status.getId() == 0) { + status.setId(db.insert(TABLE_NAME_DOWNLOAD_LOG, null, values)); + } else { + db.update(TABLE_NAME_DOWNLOAD_LOG, values, KEY_ID + "=?", + new String[]{String.valueOf(status.getId())}); + } + return status.getId(); + } + + public long getDownloadLogSize() { + final String query = String.format("SELECT COUNT(%s) FROM %s", KEY_ID, TABLE_NAME_DOWNLOAD_LOG); + Cursor result = db.rawQuery(query, null); + long count = 0; + if (result.moveToFirst()) { + count = result.getLong(0); + } + result.close(); + return count; + } + + public void removeDownloadLogItems(long count) { + if (count > 0) { + final String sql = String.format("DELETE FROM %s WHERE %s in (SELECT %s from %s ORDER BY %s ASC LIMIT %d)", + TABLE_NAME_DOWNLOAD_LOG, KEY_ID, KEY_ID, TABLE_NAME_DOWNLOAD_LOG, KEY_COMPLETION_DATE, count); + db.execSQL(sql, null); + } + } + + public void setQueue(List<FeedItem> queue) { + ContentValues values = new ContentValues(); + db.beginTransaction(); + db.delete(TABLE_NAME_QUEUE, null, null); + for (int i = 0; i < queue.size(); i++) { + FeedItem item = queue.get(i); + values.put(KEY_ID, i); + values.put(KEY_FEEDITEM, item.getId()); + values.put(KEY_FEED, item.getFeed().getId()); + db.insertWithOnConflict(TABLE_NAME_QUEUE, null, values, + SQLiteDatabase.CONFLICT_REPLACE); + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + public void clearQueue() { + db.delete(TABLE_NAME_QUEUE, null, null); + } + + public void removeFeedMedia(FeedMedia media) { + db.delete(TABLE_NAME_FEED_MEDIA, KEY_ID + "=?", + new String[]{String.valueOf(media.getId())}); + } + + public void removeChaptersOfItem(FeedItem item) { + db.delete(TABLE_NAME_SIMPLECHAPTERS, KEY_FEEDITEM + "=?", + new String[]{String.valueOf(item.getId())}); + } + + public void removeFeedImage(FeedImage image) { + db.delete(TABLE_NAME_FEED_IMAGES, KEY_ID + "=?", + new String[]{String.valueOf(image.getId())}); + } + + /** + * Remove a FeedItem and its FeedMedia entry. + */ + public void removeFeedItem(FeedItem item) { + if (item.getMedia() != null) { + removeFeedMedia(item.getMedia()); + } + if (item.getChapters() != null) { + removeChaptersOfItem(item); + } + if (item.hasItemImage()) { + removeFeedImage(item.getImage()); + } + db.delete(TABLE_NAME_FEED_ITEMS, KEY_ID + "=?", + new String[]{String.valueOf(item.getId())}); + } + + /** + * Remove a feed with all its FeedItems and Media entries. + */ + public void removeFeed(Feed feed) { + db.beginTransaction(); + if (feed.getImage() != null) { + removeFeedImage(feed.getImage()); + } + if (feed.getItems() != null) { + for (FeedItem item : feed.getItems()) { + removeFeedItem(item); + } + } + + db.delete(TABLE_NAME_FEEDS, KEY_ID + "=?", + new String[]{String.valueOf(feed.getId())}); + db.setTransactionSuccessful(); + db.endTransaction(); + } + + public void removeDownloadStatus(DownloadStatus remove) { + db.delete(TABLE_NAME_DOWNLOAD_LOG, KEY_ID + "=?", + new String[]{String.valueOf(remove.getId())}); + } + + public void clearPlaybackHistory() { + ContentValues values = new ContentValues(); + values.put(KEY_PLAYBACK_COMPLETION_DATE, 0); + db.update(TABLE_NAME_FEED_MEDIA, values, null, null); + } + + /** + * Get all Feeds from the Feed Table. + * + * @return The cursor of the query + */ + public final Cursor getAllFeedsCursor() { + Cursor c = db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, null, null, null, null, + KEY_TITLE + " COLLATE NOCASE ASC"); + return c; + } + + public final Cursor getFeedCursorDownloadUrls() { + return db.query(TABLE_NAME_FEEDS, new String[]{KEY_ID, KEY_DOWNLOAD_URL}, null, null, null, null, null); + } + + public final Cursor getExpiredFeedsCursor(long expirationTime) { + Cursor c = db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, KEY_LASTUPDATE + " < " + String.valueOf(System.currentTimeMillis() - expirationTime), + null, null, null, + null); + return c; + } + + /** + * Returns a cursor with all FeedItems of a Feed. Uses FEEDITEM_SEL_FI_SMALL + * + * @param feed The feed you want to get the FeedItems from. + * @return The cursor of the query + */ + public final Cursor getAllItemsOfFeedCursor(final Feed feed) { + return getAllItemsOfFeedCursor(feed.getId()); + } + + public final Cursor getAllItemsOfFeedCursor(final long feedId) { + Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED + + "=?", new String[]{String.valueOf(feedId)}, null, null, + null + ); + return c; + } + + /** + * Return a cursor with the SEL_FI_EXTRA selection of a single feeditem. + */ + public final Cursor getExtraInformationOfItem(final FeedItem item) { + Cursor c = db + .query(TABLE_NAME_FEED_ITEMS, SEL_FI_EXTRA, KEY_ID + "=?", + new String[]{String.valueOf(item.getId())}, null, + null, null); + return c; + } + + /** + * Returns a cursor for a DB query in the FeedMedia table for a given ID. + * + * @param item The item you want to get the FeedMedia from + * @return The cursor of the query + */ + public final Cursor getFeedMediaOfItemCursor(final FeedItem item) { + Cursor c = db.query(TABLE_NAME_FEED_MEDIA, null, KEY_ID + "=?", + new String[]{String.valueOf(item.getMedia().getId())}, null, + null, null); + return c; + } + + /** + * Returns a cursor for a DB query in the FeedImages table for a given ID. + * + * @param id ID of the FeedImage + * @return The cursor of the query + */ + public final Cursor getImageCursor(final long id) { + Cursor c = db.query(TABLE_NAME_FEED_IMAGES, null, KEY_ID + "=?", + new String[]{String.valueOf(id)}, null, null, null); + return c; + } + + public final Cursor getSimpleChaptersOfFeedItemCursor(final FeedItem item) { + Cursor c = db.query(TABLE_NAME_SIMPLECHAPTERS, null, KEY_FEEDITEM + + "=?", new String[]{String.valueOf(item.getId())}, null, + null, null + ); + return c; + } + + public final Cursor getDownloadLogCursor(final int limit) { + Cursor c = db.query(TABLE_NAME_DOWNLOAD_LOG, null, null, null, null, + null, KEY_COMPLETION_DATE + " DESC LIMIT " + limit); + return c; + } + + /** + * Returns a cursor which contains all feed items in the queue. The returned + * cursor uses the FEEDITEM_SEL_FI_SMALL selection. + */ + public final Cursor getQueueCursor() { + Object[] args = (Object[]) new String[]{ + SEL_FI_SMALL_STR + "," + TABLE_NAME_QUEUE + "." + KEY_ID, + TABLE_NAME_FEED_ITEMS, TABLE_NAME_QUEUE, + TABLE_NAME_FEED_ITEMS + "." + KEY_ID, + TABLE_NAME_QUEUE + "." + KEY_FEEDITEM, + TABLE_NAME_QUEUE + "." + KEY_ID}; + String query = String.format( + "SELECT %s FROM %s INNER JOIN %s ON %s=%s ORDER BY %s", args); + Cursor c = db.rawQuery(query, null); + /* + * Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, + * "INNER JOIN ? ON ?=?", new String[] { TABLE_NAME_QUEUE, + * TABLE_NAME_FEED_ITEMS + "." + KEY_ID, TABLE_NAME_QUEUE + "." + + * KEY_FEEDITEM }, null, null, TABLE_NAME_QUEUE + "." + KEY_FEEDITEM); + */ + return c; + } + + public Cursor getQueueIDCursor() { + Cursor c = db.query(TABLE_NAME_QUEUE, new String[]{KEY_FEEDITEM}, null, null, null, null, KEY_ID + " ASC", null); + return c; + } + + /** + * Returns a cursor which contains all feed items in the unread items list. + * The returned cursor uses the FEEDITEM_SEL_FI_SMALL selection. + */ + public final Cursor getUnreadItemsCursor() { + Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_READ + + "=0", null, null, null, KEY_PUBDATE + " DESC"); + return c; + } + + public final Cursor getUnreadItemIdsCursor() { + Cursor c = db.query(TABLE_NAME_FEED_ITEMS, new String[]{KEY_ID}, + KEY_READ + "=0", null, null, null, KEY_PUBDATE + " DESC"); + return c; + + } + + public final Cursor getRecentlyPublishedItemsCursor(int limit) { + Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, null, null, null, null, KEY_PUBDATE + " DESC LIMIT " + limit); + return c; + } + + public Cursor getDownloadedItemsCursor() { + final String query = "SELECT " + SEL_FI_SMALL_STR + " FROM " + TABLE_NAME_FEED_ITEMS + + " INNER JOIN " + TABLE_NAME_FEED_MEDIA + " ON " + + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + "=" + + TABLE_NAME_FEED_MEDIA + "." + KEY_FEEDITEM + " WHERE " + + TABLE_NAME_FEED_MEDIA + "." + KEY_DOWNLOADED + ">0"; + Cursor c = db.rawQuery(query, null); + return c; + } + + /** + * Returns a cursor which contains feed media objects with a playback + * completion date in ascending order. + * + * @param limit The maximum row count of the returned cursor. Must be an + * integer >= 0. + * @throws IllegalArgumentException if limit < 0 + */ + public final Cursor getCompletedMediaCursor(int limit) { + Validate.isTrue(limit >= 0, "Limit must be >= 0"); + + Cursor c = db.query(TABLE_NAME_FEED_MEDIA, null, + KEY_PLAYBACK_COMPLETION_DATE + " > 0 LIMIT " + limit, null, null, + null, null); + return c; + } + + public final Cursor getSingleFeedMediaCursor(long id) { + return db.query(TABLE_NAME_FEED_MEDIA, null, KEY_ID + "=?", new String[]{String.valueOf(id)}, null, null, null); + } + + public final Cursor getFeedMediaCursorByItemID(String... mediaIds) { + int length = mediaIds.length; + if (length > IN_OPERATOR_MAXIMUM) { + Log.w(TAG, "Length of id array is larger than " + + IN_OPERATOR_MAXIMUM + ". Creating multiple cursors"); + int numCursors = (int) (((double) length) / (IN_OPERATOR_MAXIMUM)) + 1; + Cursor[] cursors = new Cursor[numCursors]; + for (int i = 0; i < numCursors; i++) { + int neededLength = 0; + String[] parts = null; + final int elementsLeft = length - i * IN_OPERATOR_MAXIMUM; + + if (elementsLeft >= IN_OPERATOR_MAXIMUM) { + neededLength = IN_OPERATOR_MAXIMUM; + parts = Arrays.copyOfRange(mediaIds, i + * IN_OPERATOR_MAXIMUM, (i + 1) + * IN_OPERATOR_MAXIMUM); + } else { + neededLength = elementsLeft; + parts = Arrays.copyOfRange(mediaIds, i + * IN_OPERATOR_MAXIMUM, (i * IN_OPERATOR_MAXIMUM) + + neededLength); + } + + cursors[i] = db.rawQuery("SELECT * FROM " + + TABLE_NAME_FEED_MEDIA + " WHERE " + KEY_FEEDITEM + " IN " + + buildInOperator(neededLength), parts); + } + return new MergeCursor(cursors); + } else { + return db.query(TABLE_NAME_FEED_MEDIA, null, KEY_FEEDITEM + " IN " + + buildInOperator(length), mediaIds, null, null, null); + } + } + + /** + * Builds an IN-operator argument depending on the number of items. + */ + private String buildInOperator(int size) { + if (size == 1) { + return "(?)"; + } + StringBuffer buffer = new StringBuffer("("); + for (int i = 0; i < size - 1; i++) { + buffer.append("?,"); + } + buffer.append("?)"); + return buffer.toString(); + } + + public final Cursor getFeedCursor(final long id) { + Cursor c = db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, KEY_ID + "=" + id, null, + null, null, null); + return c; + } + + public final Cursor getFeedItemCursor(final String... ids) { + if (ids.length > IN_OPERATOR_MAXIMUM) { + throw new IllegalArgumentException( + "number of IDs must not be larger than " + + IN_OPERATOR_MAXIMUM + ); + } + + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_ID + " IN " + + buildInOperator(ids.length), ids, null, null, null); + + } + + public int getQueueSize() { + final String query = String.format("SELECT COUNT(%s) FROM %s", KEY_ID, TABLE_NAME_QUEUE); + Cursor c = db.rawQuery(query, null); + int result = 0; + if (c.moveToFirst()) { + result = c.getInt(0); + } + c.close(); + return result; + } + + public final int getNumberOfUnreadItems() { + final String query = "SELECT COUNT(DISTINCT " + KEY_ID + ") AS count FROM " + TABLE_NAME_FEED_ITEMS + + " WHERE " + KEY_READ + " = 0"; + Cursor c = db.rawQuery(query, null); + int result = 0; + if (c.moveToFirst()) { + result = c.getInt(0); + } + c.close(); + return result; + } + + public final int getNumberOfDownloadedEpisodes() { + final String query = "SELECT COUNT(DISTINCT " + KEY_ID + ") AS count FROM " + TABLE_NAME_FEED_MEDIA + + " WHERE " + KEY_DOWNLOADED + " > 0"; + + Cursor c = db.rawQuery(query, null); + int result = 0; + if (c.moveToFirst()) { + result = c.getInt(0); + } + c.close(); + return result; + } + + /** + * Uses DatabaseUtils to escape a search query and removes ' at the + * beginning and the end of the string returned by the escape method. + */ + private String prepareSearchQuery(String query) { + StringBuilder builder = new StringBuilder(); + DatabaseUtils.appendEscapedSQLString(builder, query); + builder.deleteCharAt(0); + builder.deleteCharAt(builder.length() - 1); + return builder.toString(); + } + + /** + * Searches for the given query in the description of all items or the items + * of a specified feed. + * + * @return A cursor with all search results in SEL_FI_EXTRA selection. + */ + public Cursor searchItemDescriptions(long feedID, String query) { + if (feedID != 0) { + // search items in specific feed + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED + + "=? AND " + KEY_DESCRIPTION + " LIKE '%" + + prepareSearchQuery(query) + "%'", + new String[]{String.valueOf(feedID)}, null, null, + null + ); + } else { + // search through all items + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, + KEY_DESCRIPTION + " LIKE '%" + prepareSearchQuery(query) + + "%'", null, null, null, null + ); + } + } + + /** + * Searches for the given query in the content-encoded field of all items or + * the items of a specified feed. + * + * @return A cursor with all search results in SEL_FI_EXTRA selection. + */ + public Cursor searchItemContentEncoded(long feedID, String query) { + if (feedID != 0) { + // search items in specific feed + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED + + "=? AND " + KEY_CONTENT_ENCODED + " LIKE '%" + + prepareSearchQuery(query) + "%'", + new String[]{String.valueOf(feedID)}, null, null, + null + ); + } else { + // search through all items + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, + KEY_CONTENT_ENCODED + " LIKE '%" + + prepareSearchQuery(query) + "%'", null, null, + null, null + ); + } + } + + public Cursor searchItemTitles(long feedID, String query) { + if (feedID != 0) { + // search items in specific feed + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED + + "=? AND " + KEY_TITLE + " LIKE '%" + + prepareSearchQuery(query) + "%'", + new String[]{String.valueOf(feedID)}, null, null, + null + ); + } else { + // search through all items + return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, + KEY_TITLE + " LIKE '%" + + prepareSearchQuery(query) + "%'", null, null, + null, null + ); + } + } + + public Cursor searchItemChapters(long feedID, String searchQuery) { + final String query; + if (feedID != 0) { + query = "SELECT " + SEL_FI_SMALL_STR + " FROM " + TABLE_NAME_FEED_ITEMS + " INNER JOIN " + + TABLE_NAME_SIMPLECHAPTERS + " ON " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_FEEDITEM + "=" + + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + " WHERE " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" + + feedID + " AND " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_TITLE + " LIKE '%" + + prepareSearchQuery(searchQuery) + "%'"; + } else { + query = "SELECT " + SEL_FI_SMALL_STR + " FROM " + TABLE_NAME_FEED_ITEMS + " INNER JOIN " + + TABLE_NAME_SIMPLECHAPTERS + " ON " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_FEEDITEM + "=" + + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + " WHERE " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_TITLE + " LIKE '%" + + prepareSearchQuery(searchQuery) + "%'"; + } + return db.rawQuery(query, null); + } + + + public static final int IDX_FEEDSTATISTICS_FEED = 0; + public static final int IDX_FEEDSTATISTICS_NUM_ITEMS = 1; + public static final int IDX_FEEDSTATISTICS_NEW_ITEMS = 2; + public static final int IDX_FEEDSTATISTICS_LATEST_EPISODE = 3; + public static final int IDX_FEEDSTATISTICS_IN_PROGRESS_EPISODES = 4; + + /** + * Select number of items, new items, the date of the latest episode and the number of episodes in progress. The result + * is sorted by the title of the feed. + */ + private static final String FEED_STATISTICS_QUERY = "SELECT Feeds.id, num_items, new_items, latest_episode, in_progress FROM " + + " Feeds LEFT JOIN " + + "(SELECT feed,count(*) AS num_items," + + " COUNT(CASE WHEN read=0 THEN 1 END) AS new_items," + + " MAX(pubDate) AS latest_episode," + + " COUNT(CASE WHEN position>0 THEN 1 END) AS in_progress," + + " COUNT(CASE WHEN downloaded=1 THEN 1 END) AS episodes_downloaded " + + " FROM FeedItems LEFT JOIN FeedMedia ON FeedItems.id=FeedMedia.feeditem GROUP BY FeedItems.feed)" + + " ON Feeds.id = feed ORDER BY Feeds.title COLLATE NOCASE ASC;"; + + public Cursor getFeedStatisticsCursor() { + return db.rawQuery(FEED_STATISTICS_QUERY, null); + } + + /** + * Helper class for opening the Antennapod database. + */ + private static class PodDBHelper extends SQLiteOpenHelper { + /** + * Constructor. + * + * @param context Context to use + * @param name Name of the database + * @param factory to use for creating cursor objects + * @param version number of the database + */ + public PodDBHelper(final Context context, final String name, + final CursorFactory factory, final int version) { + super(context, name, factory, version); + } + + @Override + public void onCreate(final SQLiteDatabase db) { + db.execSQL(CREATE_TABLE_FEEDS); + db.execSQL(CREATE_TABLE_FEED_ITEMS); + db.execSQL(CREATE_TABLE_FEED_IMAGES); + db.execSQL(CREATE_TABLE_FEED_MEDIA); + db.execSQL(CREATE_TABLE_DOWNLOAD_LOG); + db.execSQL(CREATE_TABLE_QUEUE); + db.execSQL(CREATE_TABLE_SIMPLECHAPTERS); + } + + @Override + public void onUpgrade(final SQLiteDatabase db, final int oldVersion, + final int newVersion) { + Log.w("DBAdapter", "Upgrading from version " + oldVersion + " to " + + newVersion + "."); + if (oldVersion <= 1) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + " ADD COLUMN " + + KEY_TYPE + " TEXT"); + } + if (oldVersion <= 2) { + db.execSQL("ALTER TABLE " + TABLE_NAME_SIMPLECHAPTERS + + " ADD COLUMN " + KEY_LINK + " TEXT"); + } + if (oldVersion <= 3) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_ITEMS + + " ADD COLUMN " + KEY_ITEM_IDENTIFIER + " TEXT"); + } + if (oldVersion <= 4) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + " ADD COLUMN " + + KEY_FEED_IDENTIFIER + " TEXT"); + } + if (oldVersion <= 5) { + db.execSQL("ALTER TABLE " + TABLE_NAME_DOWNLOAD_LOG + + " ADD COLUMN " + KEY_REASON_DETAILED + " TEXT"); + db.execSQL("ALTER TABLE " + TABLE_NAME_DOWNLOAD_LOG + + " ADD COLUMN " + KEY_DOWNLOADSTATUS_TITLE + " TEXT"); + } + if (oldVersion <= 6) { + db.execSQL("ALTER TABLE " + TABLE_NAME_SIMPLECHAPTERS + + " ADD COLUMN " + KEY_CHAPTER_TYPE + " INTEGER"); + } + if (oldVersion <= 7) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_MEDIA + + " ADD COLUMN " + KEY_PLAYBACK_COMPLETION_DATE + + " INTEGER"); + } + if (oldVersion <= 8) { + final int KEY_ID_POSITION = 0; + final int KEY_MEDIA_POSITION = 1; + + // Add feeditem column to feedmedia table + db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_MEDIA + + " ADD COLUMN " + KEY_FEEDITEM + + " INTEGER"); + Cursor feeditemCursor = db.query(TABLE_NAME_FEED_ITEMS, new String[]{KEY_ID, KEY_MEDIA}, "? > 0", new String[]{KEY_MEDIA}, null, null, null); + if (feeditemCursor.moveToFirst()) { + db.beginTransaction(); + ContentValues contentValues = new ContentValues(); + do { + long mediaId = feeditemCursor.getLong(KEY_MEDIA_POSITION); + contentValues.put(KEY_FEEDITEM, feeditemCursor.getLong(KEY_ID_POSITION)); + db.update(TABLE_NAME_FEED_MEDIA, contentValues, KEY_ID + "=?", new String[]{String.valueOf(mediaId)}); + contentValues.clear(); + } while (feeditemCursor.moveToNext()); + db.setTransactionSuccessful(); + db.endTransaction(); + } + feeditemCursor.close(); + } + if (oldVersion <= 9) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + + " ADD COLUMN " + KEY_AUTO_DOWNLOAD + + " INTEGER DEFAULT 1"); + } + if (oldVersion <= 10) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + + " ADD COLUMN " + KEY_FLATTR_STATUS + + " INTEGER"); + db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_ITEMS + + " ADD COLUMN " + KEY_FLATTR_STATUS + + " INTEGER"); + db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_MEDIA + + " ADD COLUMN " + KEY_PLAYED_DURATION + + " INTEGER"); + } + if (oldVersion <= 11) { + db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + + " ADD COLUMN " + KEY_USERNAME + + " TEXT"); + db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + + " ADD COLUMN " + KEY_PASSWORD + + " TEXT"); + db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_ITEMS + + " ADD COLUMN " + KEY_IMAGE + + " INTEGER"); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java new file mode 100644 index 000000000..aafa1c209 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.syndication.handler; + +import de.danoeh.antennapod.feed.Feed; +import org.apache.commons.io.input.XmlStreamReader; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.io.File; +import java.io.IOException; +import java.io.Reader; + +public class FeedHandler { + + public FeedHandlerResult parseFeed(Feed feed) throws SAXException, IOException, + ParserConfigurationException, UnsupportedFeedtypeException { + TypeGetter tg = new TypeGetter(); + TypeGetter.Type type = tg.getType(feed); + SyndHandler handler = new SyndHandler(feed, type); + + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + SAXParser saxParser = factory.newSAXParser(); + File file = new File(feed.getFile_url()); + Reader inputStreamReader = new XmlStreamReader(file); + InputSource inputSource = new InputSource(inputStreamReader); + + saxParser.parse(inputSource, handler); + inputStreamReader.close(); + return new FeedHandlerResult(handler.state.feed, handler.state.alternateUrls); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java new file mode 100644 index 000000000..41aa29b52 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.syndication.handler; + +import de.danoeh.antennapod.feed.Feed; + +import java.util.Map; + +/** + * Container for results returned by the Feed parser + */ +public class FeedHandlerResult { + + public Feed feed; + public Map<String, String> alternateFeedUrls; + + public FeedHandlerResult(Feed feed, Map<String, String> alternateFeedUrls) { + this.feed = feed; + this.alternateFeedUrls = alternateFeedUrls; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java new file mode 100644 index 000000000..17f84724f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java @@ -0,0 +1,98 @@ +package de.danoeh.antennapod.syndication.handler; + +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.syndication.namespace.Namespace; +import de.danoeh.antennapod.syndication.namespace.SyndElement; + +import java.util.*; + +/** + * Contains all relevant information to describe the current state of a + * SyndHandler. + */ +public class HandlerState { + + /** + * Feed that the Handler is currently processing. + */ + protected Feed feed; + /** + * Contains links to related feeds, e.g. feeds with enclosures in other formats. The key of the map is the + * URL of the feed, the value is the title + */ + protected Map<String, String> alternateUrls; + protected ArrayList<FeedItem> items; + protected FeedItem currentItem; + protected Stack<SyndElement> tagstack; + /** + * Namespaces that have been defined so far. + */ + protected HashMap<String, Namespace> namespaces; + protected Stack<Namespace> defaultNamespaces; + /** + * Buffer for saving characters. + */ + protected StringBuffer contentBuf; + + public HandlerState(Feed feed) { + this.feed = feed; + alternateUrls = new LinkedHashMap<String, String>(); + items = new ArrayList<FeedItem>(); + tagstack = new Stack<SyndElement>(); + namespaces = new HashMap<String, Namespace>(); + defaultNamespaces = new Stack<Namespace>(); + } + + public Feed getFeed() { + return feed; + } + + public ArrayList<FeedItem> getItems() { + return items; + } + + public FeedItem getCurrentItem() { + return currentItem; + } + + public Stack<SyndElement> getTagstack() { + return tagstack; + } + + public void setFeed(Feed feed) { + this.feed = feed; + } + + public void setCurrentItem(FeedItem currentItem) { + this.currentItem = currentItem; + } + + /** + * Returns the SyndElement that comes after the top element of the tagstack. + */ + public SyndElement getSecondTag() { + SyndElement top = tagstack.pop(); + SyndElement second = tagstack.peek(); + tagstack.push(top); + return second; + } + + public SyndElement getThirdTag() { + SyndElement top = tagstack.pop(); + SyndElement second = tagstack.pop(); + SyndElement third = tagstack.peek(); + tagstack.push(second); + tagstack.push(top); + return third; + } + + public StringBuffer getContentBuf() { + return contentBuf; + } + + public void addAlternateFeedUrl(String title, String url) { + alternateUrls.put(url, title); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java new file mode 100644 index 000000000..15dc94d65 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java @@ -0,0 +1,126 @@ +package de.danoeh.antennapod.syndication.handler; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.syndication.namespace.*; +import de.danoeh.antennapod.syndication.namespace.atom.NSAtom; +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +/** Superclass for all SAX Handlers which process Syndication formats */ +public class SyndHandler extends DefaultHandler { + private static final String TAG = "SyndHandler"; + private static final String DEFAULT_PREFIX = ""; + protected HandlerState state; + + public SyndHandler(Feed feed, TypeGetter.Type type) { + state = new HandlerState(feed); + if (type == TypeGetter.Type.RSS20 || type == TypeGetter.Type.RSS091) { + state.defaultNamespaces.push(new NSRSS20()); + } + } + + @Override + public void startElement(String uri, String localName, String qName, + Attributes attributes) throws SAXException { + state.contentBuf = new StringBuffer(); + Namespace handler = getHandlingNamespace(uri, qName); + if (handler != null) { + SyndElement element = handler.handleElementStart(localName, state, + attributes); + state.tagstack.push(element); + + } + } + + @Override + public void characters(char[] ch, int start, int length) + throws SAXException { + if (!state.tagstack.empty()) { + if (state.getTagstack().size() >= 2) { + if (state.contentBuf != null) { + state.contentBuf.append(ch, start, length); + } + } + } + } + + @Override + public void endElement(String uri, String localName, String qName) + throws SAXException { + Namespace handler = getHandlingNamespace(uri, qName); + if (handler != null) { + handler.handleElementEnd(localName, state); + state.tagstack.pop(); + + } + state.contentBuf = null; + + } + + @Override + public void endPrefixMapping(String prefix) throws SAXException { + if (state.defaultNamespaces.size() > 1 && prefix.equals(DEFAULT_PREFIX)) { + state.defaultNamespaces.pop(); + } + } + + @Override + public void startPrefixMapping(String prefix, String uri) + throws SAXException { + // Find the right namespace + if (!state.namespaces.containsKey(uri)) { + if (uri.equals(NSAtom.NSURI)) { + if (prefix.equals(DEFAULT_PREFIX)) { + state.defaultNamespaces.push(new NSAtom()); + } else if (prefix.equals(NSAtom.NSTAG)) { + state.namespaces.put(uri, new NSAtom()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized Atom namespace"); + } + } else if (uri.equals(NSContent.NSURI) + && prefix.equals(NSContent.NSTAG)) { + state.namespaces.put(uri, new NSContent()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized Content namespace"); + } else if (uri.equals(NSITunes.NSURI) + && prefix.equals(NSITunes.NSTAG)) { + state.namespaces.put(uri, new NSITunes()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized ITunes namespace"); + } else if (uri.equals(NSSimpleChapters.NSURI) + && prefix.matches(NSSimpleChapters.NSTAG)) { + state.namespaces.put(uri, new NSSimpleChapters()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized SimpleChapters namespace"); + } else if (uri.equals(NSMedia.NSURI) + && prefix.equals(NSMedia.NSTAG)) { + state.namespaces.put(uri, new NSMedia()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized media namespace"); + } + } + } + + private Namespace getHandlingNamespace(String uri, String qName) { + Namespace handler = state.namespaces.get(uri); + if (handler == null && !state.defaultNamespaces.empty() + && !qName.contains(":")) { + handler = state.defaultNamespaces.peek(); + } + return handler; + } + + @Override + public void endDocument() throws SAXException { + super.endDocument(); + state.getFeed().setItems(state.getItems()); + } + + public HandlerState getState() { + return state; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java new file mode 100644 index 000000000..2496e112a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java @@ -0,0 +1,112 @@ +package de.danoeh.antennapod.syndication.handler; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Feed; +import org.apache.commons.io.input.XmlStreamReader; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.Reader; + +/** Gets the type of a specific feed by reading the root element. */ +public class TypeGetter { + private static final String TAG = "TypeGetter"; + + public enum Type { + RSS20, RSS091, ATOM, INVALID + } + + private static final String ATOM_ROOT = "feed"; + private static final String RSS_ROOT = "rss"; + + public Type getType(Feed feed) throws UnsupportedFeedtypeException { + XmlPullParserFactory factory; + if (feed.getFile_url() != null) { + try { + factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + XmlPullParser xpp = factory.newPullParser(); + xpp.setInput(createReader(feed)); + int eventType = xpp.getEventType(); + + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG) { + String tag = xpp.getName(); + if (tag.equals(ATOM_ROOT)) { + feed.setType(Feed.TYPE_ATOM1); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized type Atom"); + return Type.ATOM; + } else if (tag.equals(RSS_ROOT)) { + String strVersion = xpp.getAttributeValue(null, + "version"); + if (strVersion != null) { + + if (strVersion.equals("2.0")) { + feed.setType(Feed.TYPE_RSS2); + if (BuildConfig.DEBUG) + Log.d(TAG, "Recognized type RSS 2.0"); + return Type.RSS20; + } else if (strVersion.equals("0.91") + || strVersion.equals("0.92")) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Recognized type RSS 0.91/0.92"); + return Type.RSS091; + } + } + throw new UnsupportedFeedtypeException(Type.INVALID); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Type is invalid"); + throw new UnsupportedFeedtypeException(Type.INVALID, tag); + } + } else { + eventType = xpp.next(); + } + } + + } catch (XmlPullParserException e) { + e.printStackTrace(); + // XML document might actually be a HTML document -> try to parse as HTML + String rootElement = null; + try { + if (Jsoup.parse(new File(feed.getFile_url()), null) != null) { + rootElement = "html"; + } + } catch (IOException e1) { + e1.printStackTrace(); + } finally { + throw new UnsupportedFeedtypeException(Type.INVALID, rootElement); + } + + } catch (IOException e) { + e.printStackTrace(); + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Type is invalid"); + throw new UnsupportedFeedtypeException(Type.INVALID); + } + + private Reader createReader(Feed feed) { + Reader reader; + try { + reader = new XmlStreamReader(new File(feed.getFile_url())); + } catch (FileNotFoundException e) { + e.printStackTrace(); + return null; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + return reader; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java new file mode 100644 index 000000000..605dad2fb --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.syndication.handler; + +import de.danoeh.antennapod.syndication.handler.TypeGetter.Type; + +public class UnsupportedFeedtypeException extends Exception { + private static final long serialVersionUID = 9105878964928170669L; + private TypeGetter.Type type; + private String rootElement; + + public UnsupportedFeedtypeException(Type type) { + super(); + this.type = type; + } + + public UnsupportedFeedtypeException(Type type, String rootElement) { + this.type = type; + this.rootElement = rootElement; + } + + public TypeGetter.Type getType() { + return type; + } + + public String getRootElement() { + return rootElement; + } + + @Override + public String getMessage() { + if (type == TypeGetter.Type.INVALID) { + return "Invalid type"; + } else { + return "Type " + type + " not supported"; + } + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java new file mode 100644 index 000000000..9ad3026be --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.syndication.namespace; + +import de.danoeh.antennapod.syndication.handler.HandlerState; +import org.xml.sax.Attributes; + +public class NSContent extends Namespace { + public static final String NSTAG = "content"; + public static final String NSURI = "http://purl.org/rss/1.0/modules/content/"; + + private static final String ENCODED = "encoded"; + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, + Attributes attributes) { + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + if (localName.equals(ENCODED)) { + state.getCurrentItem().setContentEncoded(state.getContentBuf().toString()); + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java new file mode 100644 index 000000000..d8cbe040b --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java @@ -0,0 +1,51 @@ +package de.danoeh.antennapod.syndication.namespace; + +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.syndication.handler.HandlerState; +import org.xml.sax.Attributes; + +public class NSITunes extends Namespace { + public static final String NSTAG = "itunes"; + public static final String NSURI = "http://www.itunes.com/dtds/podcast-1.0.dtd"; + + private static final String IMAGE = "image"; + private static final String IMAGE_TITLE = "image"; + private static final String IMAGE_HREF = "href"; + + private static final String AUTHOR = "author"; + + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, + Attributes attributes) { + if (localName.equals(IMAGE)) { + FeedImage image = new FeedImage(); + image.setTitle(IMAGE_TITLE); + image.setDownload_url(attributes.getValue(IMAGE_HREF)); + + if (state.getCurrentItem() != null) { + // this is an items image + image.setTitle(state.getCurrentItem().getTitle() + IMAGE_TITLE); + state.getCurrentItem().setImage(image); + + } else { + // this is the feed image + if (state.getFeed().getImage() == null) { + state.getFeed().setImage(image); + } + } + + } + + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + if (localName.equals(AUTHOR)) { + state.getFeed().setAuthor(state.getContentBuf().toString()); + } + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java new file mode 100644 index 000000000..cc23167c1 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java @@ -0,0 +1,68 @@ +package de.danoeh.antennapod.syndication.namespace; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.syndication.handler.HandlerState; +import de.danoeh.antennapod.syndication.util.SyndTypeUtils; +import org.xml.sax.Attributes; + +import java.util.concurrent.TimeUnit; + +/** Processes tags from the http://search.yahoo.com/mrss/ namespace. */ +public class NSMedia extends Namespace { + private static final String TAG = "NSMedia"; + + public static final String NSTAG = "media"; + public static final String NSURI = "http://search.yahoo.com/mrss/"; + + private static final String CONTENT = "content"; + private static final String DOWNLOAD_URL = "url"; + private static final String SIZE = "fileSize"; + private static final String MIME_TYPE = "type"; + private static final String DURATION = "duration"; + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, + Attributes attributes) { + if (localName.equals(CONTENT)) { + String url = attributes.getValue(DOWNLOAD_URL); + String type = attributes.getValue(MIME_TYPE); + if (state.getCurrentItem().getMedia() == null + && url != null + && (SyndTypeUtils.enclosureTypeValid(type) || ((type = SyndTypeUtils + .getValidMimeTypeFromUrl(url)) != null))) { + + long size = 0; + try { + size = Long.parseLong(attributes.getValue(SIZE)); + } catch (NumberFormatException e) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Length attribute could not be parsed."); + } + + int duration = 0; + try { + String durationStr = attributes.getValue(DURATION); + if (durationStr != null) { + duration = (int) TimeUnit.MILLISECONDS.convert( + Long.parseLong(durationStr), TimeUnit.SECONDS); + } + } catch (NumberFormatException e) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Duration attribute could not be parsed"); + } + + state.getCurrentItem().setMedia( + new FeedMedia(state.getCurrentItem(), url, size, type)); + } + } + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java new file mode 100644 index 000000000..9572f87ae --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java @@ -0,0 +1,141 @@ +package de.danoeh.antennapod.syndication.namespace; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.syndication.handler.HandlerState; +import de.danoeh.antennapod.syndication.util.SyndDateUtils; +import de.danoeh.antennapod.syndication.util.SyndTypeUtils; +import org.xml.sax.Attributes; + +/** + * SAX-Parser for reading RSS-Feeds + * + * @author daniel + * + */ +public class NSRSS20 extends Namespace { + private static final String TAG = "NSRSS20"; + public static final String NSTAG = "rss"; + public static final String NSURI = ""; + + public final static String CHANNEL = "channel"; + public final static String ITEM = "item"; + public final static String GUID = "guid"; + public final static String TITLE = "title"; + public final static String LINK = "link"; + public final static String DESCR = "description"; + public final static String PUBDATE = "pubDate"; + public final static String ENCLOSURE = "enclosure"; + public final static String IMAGE = "image"; + public final static String URL = "url"; + public final static String LANGUAGE = "language"; + + public final static String ENC_URL = "url"; + public final static String ENC_LEN = "length"; + public final static String ENC_TYPE = "type"; + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, + Attributes attributes) { + if (localName.equals(ITEM)) { + state.setCurrentItem(new FeedItem()); + state.getItems().add(state.getCurrentItem()); + state.getCurrentItem().setFeed(state.getFeed()); + + } else if (localName.equals(ENCLOSURE)) { + String type = attributes.getValue(ENC_TYPE); + String url = attributes.getValue(ENC_URL); + if (state.getCurrentItem().getMedia() == null + && (SyndTypeUtils.enclosureTypeValid(type) || ((type = SyndTypeUtils + .getValidMimeTypeFromUrl(url)) != null))) { + + long size = 0; + try { + size = Long.parseLong(attributes.getValue(ENC_LEN)); + } catch (NumberFormatException e) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Length attribute could not be parsed."); + } + state.getCurrentItem().setMedia( + new FeedMedia(state.getCurrentItem(), url, size, type)); + } + + } else if (localName.equals(IMAGE)) { + if (state.getTagstack().size() >= 1) { + String parent = state.getTagstack().peek().getName(); + if (parent.equals(CHANNEL)) { + state.getFeed().setImage(new FeedImage()); + } + } + } + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + if (localName.equals(ITEM)) { + if (state.getCurrentItem() != null) { + // the title tag is optional in RSS 2.0. The description is used + // as a + // title if the item has no title-tag. + if (state.getCurrentItem().getTitle() == null) { + state.getCurrentItem().setTitle( + state.getCurrentItem().getDescription()); + } + } + state.setCurrentItem(null); + } else if (state.getTagstack().size() >= 2 + && state.getContentBuf() != null) { + String content = state.getContentBuf().toString(); + SyndElement topElement = state.getTagstack().peek(); + String top = topElement.getName(); + SyndElement secondElement = state.getSecondTag(); + String second = secondElement.getName(); + String third = null; + if (state.getTagstack().size() >= 3) { + third = state.getThirdTag().getName(); + } + + if (top.equals(GUID) && second.equals(ITEM)) { + // some feed creators include an empty or non-standard guid-element in their feed, which should be ignored + if (!content.isEmpty()) { + state.getCurrentItem().setItemIdentifier(content); + } + } else if (top.equals(TITLE)) { + if (second.equals(ITEM)) { + state.getCurrentItem().setTitle(content); + } else if (second.equals(CHANNEL)) { + state.getFeed().setTitle(content); + } else if (second.equals(IMAGE) && third != null + && third.equals(CHANNEL)) { + state.getFeed().getImage().setTitle(content); + } + } else if (top.equals(LINK)) { + if (second.equals(CHANNEL)) { + state.getFeed().setLink(content); + } else if (second.equals(ITEM)) { + state.getCurrentItem().setLink(content); + } + } else if (top.equals(PUBDATE) && second.equals(ITEM)) { + state.getCurrentItem().setPubDate( + SyndDateUtils.parseRFC822Date(content)); + } else if (top.equals(URL) && second.equals(IMAGE) && third != null + && third.equals(CHANNEL)) { + state.getFeed().getImage().setDownload_url(content); + } else if (localName.equals(DESCR)) { + if (second.equals(CHANNEL)) { + state.getFeed().setDescription(content); + } else if (second.equals(ITEM)) { + state.getCurrentItem().setDescription(content); + } + + } else if (localName.equals(LANGUAGE)) { + state.getFeed().setLanguage(content.toLowerCase()); + } + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java new file mode 100644 index 000000000..3f983ee88 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java @@ -0,0 +1,42 @@ +package de.danoeh.antennapod.syndication.namespace; + +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.SimpleChapter; +import de.danoeh.antennapod.syndication.handler.HandlerState; +import de.danoeh.antennapod.syndication.util.SyndDateUtils; +import org.xml.sax.Attributes; + +import java.util.ArrayList; + +public class NSSimpleChapters extends Namespace { + public static final String NSTAG = "psc|sc"; + public static final String NSURI = "http://podlove.org/simple-chapters"; + + public static final String CHAPTERS = "chapters"; + public static final String CHAPTER = "chapter"; + public static final String START = "start"; + public static final String TITLE = "title"; + public static final String HREF = "href"; + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, + Attributes attributes) { + if (localName.equals(CHAPTERS)) { + state.getCurrentItem().setChapters(new ArrayList<Chapter>()); + } else if (localName.equals(CHAPTER)) { + state.getCurrentItem() + .getChapters() + .add(new SimpleChapter(SyndDateUtils + .parseTimeString(attributes.getValue(START)), + attributes.getValue(TITLE), state.getCurrentItem(), + attributes.getValue(HREF))); + } + + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java new file mode 100644 index 000000000..910131feb --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.syndication.namespace; + +import de.danoeh.antennapod.syndication.handler.HandlerState; +import org.xml.sax.Attributes; + + +public abstract class Namespace { + public static final String NSTAG = null; + public static final String NSURI = null; + + /** Called by a Feedhandler when in startElement and it detects a namespace element + * @return The SyndElement to push onto the stack + * */ + public abstract SyndElement handleElementStart(String localName, HandlerState state, Attributes attributes); + + /** Called by a Feedhandler when in endElement and it detects a namespace element + * @return true if namespace handled the element, false if it ignored it + * */ + public abstract void handleElementEnd(String localName, HandlerState state); + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java new file mode 100644 index 000000000..187312c9e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java @@ -0,0 +1,22 @@ +package de.danoeh.antennapod.syndication.namespace; + +/** Defines a XML Element that is pushed on the tagstack */ +public class SyndElement { + protected String name; + protected Namespace namespace; + + public SyndElement(String name, Namespace namespace) { + this.name = name; + this.namespace = namespace; + } + + public Namespace getNamespace() { + return namespace; + } + + public String getName() { + return name; + } + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java new file mode 100644 index 000000000..86b80d2ed --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.syndication.namespace.atom; + +import de.danoeh.antennapod.syndication.namespace.Namespace; +import de.danoeh.antennapod.syndication.namespace.SyndElement; +import org.apache.commons.lang3.StringEscapeUtils; + +/** Represents Atom Element which contains text (content, title, summary). */ +public class AtomText extends SyndElement { + public static final String TYPE_TEXT = "text"; + public static final String TYPE_HTML = "html"; + public static final String TYPE_XHTML = "xhtml"; + + private String type; + private String content; + + public AtomText(String name, Namespace namespace, String type) { + super(name, namespace); + this.type = type; + } + + /** Processes the content according to the type and returns it. */ + public String getProcessedContent() { + if (type == null) { + return content; + } else if (type.equals(TYPE_HTML)) { + return StringEscapeUtils.unescapeHtml4(content); + } else if (type.equals(TYPE_XHTML)) { + return content; + } else { // Handle as text by default + return content; + } + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getType() { + return type; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java new file mode 100644 index 000000000..2c8e232ff --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java @@ -0,0 +1,194 @@ +package de.danoeh.antennapod.syndication.namespace.atom; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.FeedImage; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.syndication.handler.HandlerState; +import de.danoeh.antennapod.syndication.namespace.NSRSS20; +import de.danoeh.antennapod.syndication.namespace.Namespace; +import de.danoeh.antennapod.syndication.namespace.SyndElement; +import de.danoeh.antennapod.syndication.util.SyndDateUtils; +import de.danoeh.antennapod.syndication.util.SyndTypeUtils; +import org.xml.sax.Attributes; + +public class NSAtom extends Namespace { + private static final String TAG = "NSAtom"; + public static final String NSTAG = "atom"; + public static final String NSURI = "http://www.w3.org/2005/Atom"; + + private static final String FEED = "feed"; + private static final String ID = "id"; + private static final String TITLE = "title"; + private static final String ENTRY = "entry"; + private static final String LINK = "link"; + private static final String UPDATED = "updated"; + private static final String AUTHOR = "author"; + private static final String CONTENT = "content"; + private static final String IMAGE = "logo"; + private static final String SUBTITLE = "subtitle"; + private static final String PUBLISHED = "published"; + + private static final String TEXT_TYPE = "type"; + // Link + private static final String LINK_HREF = "href"; + private static final String LINK_REL = "rel"; + private static final String LINK_TYPE = "type"; + private static final String LINK_TITLE = "title"; + private static final String LINK_LENGTH = "length"; + // rel-values + private static final String LINK_REL_ALTERNATE = "alternate"; + private static final String LINK_REL_ENCLOSURE = "enclosure"; + private static final String LINK_REL_PAYMENT = "payment"; + private static final String LINK_REL_RELATED = "related"; + private static final String LINK_REL_SELF = "self"; + // type-values + private static final String LINK_TYPE_ATOM = "application/atom+xml"; + private static final String LINK_TYPE_HTML = "text/html"; + private static final String LINK_TYPE_XHTML = "application/xml+xhtml"; + + private static final String LINK_TYPE_RSS = "application/rss+xml"; + + /** + * Regexp to test whether an Element is a Text Element. + */ + private static final String isText = TITLE + "|" + CONTENT + "|" + "|" + + SUBTITLE; + + public static final String isFeed = FEED + "|" + NSRSS20.CHANNEL; + public static final String isFeedItem = ENTRY + "|" + NSRSS20.ITEM; + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, + Attributes attributes) { + if (localName.equals(ENTRY)) { + state.setCurrentItem(new FeedItem()); + state.getItems().add(state.getCurrentItem()); + state.getCurrentItem().setFeed(state.getFeed()); + } else if (localName.matches(isText)) { + String type = attributes.getValue(TEXT_TYPE); + return new AtomText(localName, this, type); + } else if (localName.equals(LINK)) { + String href = attributes.getValue(LINK_HREF); + String rel = attributes.getValue(LINK_REL); + SyndElement parent = state.getTagstack().peek(); + if (parent.getName().matches(isFeedItem)) { + if (rel == null || rel.equals(LINK_REL_ALTERNATE)) { + state.getCurrentItem().setLink(href); + } else if (rel.equals(LINK_REL_ENCLOSURE)) { + String strSize = attributes.getValue(LINK_LENGTH); + long size = 0; + try { + if (strSize != null) { + size = Long.parseLong(strSize); + } + } catch (NumberFormatException e) { + if (BuildConfig.DEBUG) Log.d(TAG, "Length attribute could not be parsed."); + } + String type = attributes.getValue(LINK_TYPE); + if (SyndTypeUtils.enclosureTypeValid(type) + || (type = SyndTypeUtils + .getValidMimeTypeFromUrl(href)) != null) { + state.getCurrentItem().setMedia( + new FeedMedia(state.getCurrentItem(), href, + size, type) + ); + } + } else if (rel.equals(LINK_REL_PAYMENT)) { + state.getCurrentItem().setPaymentLink(href); + } + } else if (parent.getName().matches(isFeed)) { + if (rel == null || rel.equals(LINK_REL_ALTERNATE)) { + String type = attributes.getValue(LINK_TYPE); + /* + * Use as link if a) no type-attribute is given and + * feed-object has no link yet b) type of link is + * LINK_TYPE_HTML or LINK_TYPE_XHTML + */ + if ((type == null && state.getFeed().getLink() == null) + || (type != null && (type.equals(LINK_TYPE_HTML) || type.equals(LINK_TYPE_XHTML)))) { + state.getFeed().setLink(href); + } else if (type != null && (type.equals(LINK_TYPE_ATOM) || type.equals(LINK_TYPE_RSS))) { + // treat as podlove alternate feed + String title = attributes.getValue(LINK_TITLE); + if (title == null) { + title = href; + } + state.addAlternateFeedUrl(title, href); + } + } else if (rel.equals(LINK_REL_PAYMENT)) { + state.getFeed().setPaymentLink(href); + } + } + } + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + if (localName.equals(ENTRY)) { + state.setCurrentItem(null); + } + + if (state.getTagstack().size() >= 2) { + AtomText textElement = null; + String content; + if (state.getContentBuf() != null) { + content = state.getContentBuf().toString(); + } else { + content = ""; + } + SyndElement topElement = state.getTagstack().peek(); + String top = topElement.getName(); + SyndElement secondElement = state.getSecondTag(); + String second = secondElement.getName(); + + if (top.matches(isText)) { + textElement = (AtomText) topElement; + textElement.setContent(content); + } + + if (top.equals(ID)) { + if (second.equals(FEED)) { + state.getFeed().setFeedIdentifier(content); + } else if (second.equals(ENTRY)) { + state.getCurrentItem().setItemIdentifier(content); + } + } else if (top.equals(TITLE)) { + + if (second.equals(FEED)) { + state.getFeed().setTitle(textElement.getProcessedContent()); + } else if (second.equals(ENTRY)) { + state.getCurrentItem().setTitle( + textElement.getProcessedContent()); + } + } else if (top.equals(SUBTITLE)) { + if (second.equals(FEED)) { + state.getFeed().setDescription( + textElement.getProcessedContent()); + } + } else if (top.equals(CONTENT)) { + if (second.equals(ENTRY)) { + state.getCurrentItem().setDescription( + textElement.getProcessedContent()); + } + } else if (top.equals(UPDATED)) { + if (second.equals(ENTRY) + && state.getCurrentItem().getPubDate() == null) { + state.getCurrentItem().setPubDate( + SyndDateUtils.parseRFC3339Date(content)); + } + } else if (top.equals(PUBLISHED)) { + if (second.equals(ENTRY)) { + state.getCurrentItem().setPubDate( + SyndDateUtils.parseRFC3339Date(content)); + } + } else if (top.equals(IMAGE)) { + state.getFeed().setImage(new FeedImage(content, null)); + } + + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java new file mode 100644 index 000000000..3138f087a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java @@ -0,0 +1,153 @@ +package de.danoeh.antennapod.syndication.util; + +import android.util.Log; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** Parses several date formats. */ +public class SyndDateUtils { + private static final String TAG = "DateUtils"; + + private static final String[] RFC822DATES = { "dd MMM yy HH:mm:ss Z", }; + + /** RFC 3339 date format for UTC dates. */ + public static final String RFC3339UTC = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + + /** RFC 3339 date format for localtime dates with offset. */ + public static final String RFC3339LOCAL = "yyyy-MM-dd'T'HH:mm:ssZ"; + + private static ThreadLocal<SimpleDateFormat> RFC822Formatter = new ThreadLocal<SimpleDateFormat>() { + @Override + protected SimpleDateFormat initialValue() { + return new SimpleDateFormat(RFC822DATES[0], Locale.US); + } + + }; + + private static ThreadLocal<SimpleDateFormat> RFC3339Formatter = new ThreadLocal<SimpleDateFormat>() { + @Override + protected SimpleDateFormat initialValue() { + return new SimpleDateFormat(RFC3339UTC, Locale.US); + } + + }; + + public static Date parseRFC822Date(String date) { + Date result = null; + if (date.contains("PDT")) { + date = date.replace("PDT", "PST8PDT"); + } + if (date.contains(",")) { + // Remove day of the week + date = date.substring(date.indexOf(",") + 1).trim(); + } + SimpleDateFormat format = RFC822Formatter.get(); + for (int i = 0; i < RFC822DATES.length; i++) { + try { + format.applyPattern(RFC822DATES[i]); + result = format.parse(date); + break; + } catch (ParseException e) { + e.printStackTrace(); + } + } + if (result == null) { + Log.e(TAG, "Unable to parse feed date correctly"); + } + + return result; + } + + public static Date parseRFC3339Date(String date) { + Date result = null; + SimpleDateFormat format = RFC3339Formatter.get(); + boolean isLocal = date.endsWith("Z"); + if (date.contains(".")) { + // remove secfrac + int fracIndex = date.indexOf("."); + String first = date.substring(0, fracIndex); + String second = null; + if (isLocal) { + second = date.substring(date.length() - 1); + } else { + if (date.contains("+")) { + second = date.substring(date.indexOf("+")); + } else { + second = date.substring(date.indexOf("-")); + } + } + + date = first + second; + } + if (isLocal) { + try { + result = format.parse(date); + } catch (ParseException e) { + e.printStackTrace(); + } + } else { + format.applyPattern(RFC3339LOCAL); + // remove last colon + StringBuffer buf = new StringBuffer(date.length() - 1); + int colonIdx = date.lastIndexOf(':'); + for (int x = 0; x < date.length(); x++) { + if (x != colonIdx) + buf.append(date.charAt(x)); + } + String bufStr = buf.toString(); + try { + result = format.parse(bufStr); + } catch (ParseException e) { + e.printStackTrace(); + Log.e(TAG, "Unable to parse date"); + } finally { + format.applyPattern(RFC3339UTC); + } + + } + + return result; + + } + + /** + * Takes a string of the form [HH:]MM:SS[.mmm] and converts it to + * milliseconds. + */ + public static long parseTimeString(final String time) { + String[] parts = time.split(":"); + long result = 0; + int idx = 0; + if (parts.length == 3) { + // string has hours + result += Integer.valueOf(parts[idx]) * 3600000L; + idx++; + } + result += Integer.valueOf(parts[idx]) * 60000L; + idx++; + result += (Float.valueOf(parts[idx])) * 1000L; + return result; + } + + public static String formatRFC822Date(Date date) { + SimpleDateFormat format = RFC822Formatter.get(); + return format.format(date); + } + + public static String formatRFC3339Local(Date date) { + SimpleDateFormat format = RFC3339Formatter.get(); + format.applyPattern(RFC3339LOCAL); + String result = format.format(date); + format.applyPattern(RFC3339UTC); + return result; + } + + public static String formatRFC3339UTC(Date date) { + SimpleDateFormat format = RFC3339Formatter.get(); + format.applyPattern(RFC3339UTC); + return format.format(date); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java new file mode 100644 index 000000000..d0fa3a5fc --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java @@ -0,0 +1,42 @@ +package de.danoeh.antennapod.syndication.util; + +import android.webkit.MimeTypeMap; +import org.apache.commons.io.FilenameUtils; + +/** Utility class for handling MIME-Types of enclosures */ +public class SyndTypeUtils { + + private final static String VALID_MIMETYPE = "audio/.*" + "|" + "video/.*" + + "|" + "application/ogg"; + + private SyndTypeUtils() { + + } + + public static boolean enclosureTypeValid(String type) { + if (type == null) { + return false; + } else { + return type.matches(VALID_MIMETYPE); + } + } + + /** + * Should be used if mime-type of enclosure tag is not supported. This + * method will check if the mime-type of the file extension is supported. If + * the type is not supported, this method will return null. + */ + public static String getValidMimeTypeFromUrl(String url) { + if (url != null) { + String extension = FilenameUtils.getExtension(url); + if (extension != null) { + String type = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(extension); + if (type != null && enclosureTypeValid(type)) { + return type; + } + } + } + return null; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java b/app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java new file mode 100644 index 000000000..9e1c50674 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java @@ -0,0 +1,261 @@ +package de.danoeh.antennapod.util; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.util.comparator.ChapterStartTimeComparator; +import de.danoeh.antennapod.util.id3reader.ChapterReader; +import de.danoeh.antennapod.util.id3reader.ID3ReaderException; +import de.danoeh.antennapod.util.playback.Playable; +import de.danoeh.antennapod.util.vorbiscommentreader.VorbisCommentChapterReader; +import de.danoeh.antennapod.util.vorbiscommentreader.VorbisCommentReaderException; +import org.apache.commons.io.IOUtils; + +import java.io.*; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.List; + +/** Utility class for getting chapter data from media files. */ +public class ChapterUtils { + private static final String TAG = "ChapterUtils"; + + private ChapterUtils() { + } + + /** + * Uses the download URL of a media object of a feeditem to read its ID3 + * chapters. + */ + public static void readID3ChaptersFromPlayableStreamUrl(Playable p) { + if (p != null && p.getStreamUrl() != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle()); + InputStream in = null; + try { + URL url = new URL(p.getStreamUrl()); + ChapterReader reader = new ChapterReader(); + + in = url.openStream(); + reader.readInputStream(in); + List<Chapter> chapters = reader.getChapters(); + + if (chapters != null) { + Collections + .sort(chapters, new ChapterStartTimeComparator()); + processChapters(chapters, p); + if (chaptersValid(chapters)) { + p.setChapters(chapters); + Log.i(TAG, "Chapters loaded"); + } else { + Log.e(TAG, "Chapter data was invalid"); + } + } else { + Log.i(TAG, "ChapterReader could not find any ID3 chapters"); + } + } catch (MalformedURLException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (ID3ReaderException e) { + e.printStackTrace(); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } else { + Log.e(TAG, + "Unable to read ID3 chapters: media or download URL was null"); + } + } + + /** + * Uses the file URL of a media object of a feeditem to read its ID3 + * chapters. + */ + public static void readID3ChaptersFromPlayableFileUrl(Playable p) { + if (p != null && p.localFileAvailable() && p.getLocalMediaUrl() != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle()); + File source = new File(p.getLocalMediaUrl()); + if (source.exists()) { + ChapterReader reader = new ChapterReader(); + InputStream in = null; + + try { + in = new BufferedInputStream(new FileInputStream(source)); + reader.readInputStream(in); + List<Chapter> chapters = reader.getChapters(); + + if (chapters != null) { + Collections.sort(chapters, + new ChapterStartTimeComparator()); + processChapters(chapters, p); + if (chaptersValid(chapters)) { + p.setChapters(chapters); + Log.i(TAG, "Chapters loaded"); + } else { + Log.e(TAG, "Chapter data was invalid"); + } + } else { + Log.i(TAG, + "ChapterReader could not find any ID3 chapters"); + } + } catch (IOException e) { + e.printStackTrace(); + } catch (ID3ReaderException e) { + e.printStackTrace(); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } else { + Log.e(TAG, "Unable to read id3 chapters: Source doesn't exist"); + } + } + } + + public static void readOggChaptersFromPlayableStreamUrl(Playable media) { + if (media != null && media.streamAvailable()) { + InputStream input = null; + try { + URL url = new URL(media.getStreamUrl()); + input = url.openStream(); + if (input != null) { + readOggChaptersFromInputStream(media, input); + } + } catch (MalformedURLException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + IOUtils.closeQuietly(input); + } + } + } + + public static void readOggChaptersFromPlayableFileUrl(Playable media) { + if (media != null && media.getLocalMediaUrl() != null) { + File source = new File(media.getLocalMediaUrl()); + if (source.exists()) { + InputStream input = null; + try { + input = new BufferedInputStream(new FileInputStream(source)); + readOggChaptersFromInputStream(media, input); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } finally { + IOUtils.closeQuietly(input); + } + } + } + } + + private static void readOggChaptersFromInputStream(Playable p, + InputStream input) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Trying to read chapters from item with title " + + p.getEpisodeTitle()); + try { + VorbisCommentChapterReader reader = new VorbisCommentChapterReader(); + reader.readInputStream(input); + List<Chapter> chapters = reader.getChapters(); + if (chapters != null) { + Collections.sort(chapters, new ChapterStartTimeComparator()); + processChapters(chapters, p); + if (chaptersValid(chapters)) { + p.setChapters(chapters); + Log.i(TAG, "Chapters loaded"); + } else { + Log.e(TAG, "Chapter data was invalid"); + } + } else { + Log.i(TAG, + "ChapterReader could not find any Ogg vorbis chapters"); + } + } catch (VorbisCommentReaderException e) { + e.printStackTrace(); + } + } + + /** Makes sure that chapter does a title and an item attribute. */ + private static void processChapters(List<Chapter> chapters, Playable p) { + for (int i = 0; i < chapters.size(); i++) { + Chapter c = chapters.get(i); + if (c.getTitle() == null) { + c.setTitle(Integer.toString(i)); + } + } + } + + private static boolean chaptersValid(List<Chapter> chapters) { + if (chapters.isEmpty()) { + return false; + } + for (Chapter c : chapters) { + if (c.getTitle() == null) { + return false; + } + if (c.getStart() < 0) { + return false; + } + } + return true; + } + + /** Calls getCurrentChapter with current position. */ + public static Chapter getCurrentChapter(Playable media) { + if (media.getChapters() != null) { + List<Chapter> chapters = media.getChapters(); + Chapter current = null; + if (chapters != null) { + current = chapters.get(0); + for (Chapter sc : chapters) { + if (sc.getStart() > media.getPosition()) { + break; + } else { + current = sc; + } + } + } + return current; + } else { + return null; + } + } + + public static void loadChaptersFromStreamUrl(Playable media) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Starting chapterLoader thread"); + ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media); + if (media.getChapters() == null) { + ChapterUtils.readOggChaptersFromPlayableStreamUrl(media); + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "ChapterLoaderThread has finished"); + } + + public static void loadChaptersFromFileUrl(Playable media) { + if (media.localFileAvailable()) { + ChapterUtils.readID3ChaptersFromPlayableFileUrl(media); + if (media.getChapters() == null) { + ChapterUtils.readOggChaptersFromPlayableFileUrl(media); + } + } else { + Log.e(TAG, "Could not load chapters from file url: local file not available"); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/Converter.java b/app/src/main/java/de/danoeh/antennapod/util/Converter.java new file mode 100644 index 000000000..f4c2b2f66 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/Converter.java @@ -0,0 +1,103 @@ +package de.danoeh.antennapod.util; + +import android.util.Log; + +/** Provides methods for converting various units. */ +public final class Converter { + /** Class shall not be instantiated. */ + private Converter() { + } + + /** Logging tag. */ + private static final String TAG = "Converter"; + + + /** Indicates that the value is in the Byte range.*/ + private static final int B_RANGE = 0; + /** Indicates that the value is in the Kilobyte range.*/ + private static final int KB_RANGE = 1; + /** Indicates that the value is in the Megabyte range.*/ + private static final int MB_RANGE = 2; + /** Indicates that the value is in the Gigabyte range.*/ + private static final int GB_RANGE = 3; + /** Determines the length of the number for best readability.*/ + private static final int NUM_LENGTH = 1024; + + + private static final int HOURS_MIL = 3600000; + private static final int MINUTES_MIL = 60000; + private static final int SECONDS_MIL = 1000; + + /** Takes a byte-value and converts it into a more readable + * String. + * @param input The value to convert + * @return The converted String with a unit + * */ + public static String byteToString(final long input) { + int i = 0; + int result = 0; + + for (i = 0; i < GB_RANGE + 1; i++) { + result = (int) (input / Math.pow(1024, i)); + if (result < NUM_LENGTH) { + break; + } + } + + switch (i) { + case B_RANGE: + return result + " B"; + case KB_RANGE: + return result + " KB"; + case MB_RANGE: + return result + " MB"; + case GB_RANGE: + return result + " GB"; + default: + Log.e(TAG, "Error happened in byteToString"); + return "ERROR"; + } + } + + /** Converts milliseconds to a string containing hours, minutes and seconds */ + public static String getDurationStringLong(int duration) { + int h = duration / HOURS_MIL; + int rest = duration - h * HOURS_MIL; + int m = rest / MINUTES_MIL; + rest -= m * MINUTES_MIL; + int s = rest / SECONDS_MIL; + + return String.format("%02d:%02d:%02d", h, m, s); + } + + /** Converts milliseconds to a string containing hours and minutes */ + public static String getDurationStringShort(int duration) { + int h = duration / HOURS_MIL; + int rest = duration - h * HOURS_MIL; + int m = rest / MINUTES_MIL; + + return String.format("%02d:%02d", h, m); + } + + /** Converts long duration string (HH:MM:SS) to milliseconds. */ + public static int durationStringLongToMs(String input) { + String[] parts = input.split(":"); + if (parts.length != 3) { + return 0; + } + return Integer.valueOf(parts[0]) * 3600 * 1000 + + Integer.valueOf(parts[1]) * 60 * 1000 + + Integer.valueOf(parts[2]) * 1000; + } + + /** Converts short duration string (HH:MM) to milliseconds. */ + public static int durationStringShortToMs(String input) { + String[] parts = input.split(":"); + if (parts.length != 2) { + return 0; + } + return Integer.valueOf(parts[0]) * 3600 * 1000 + + Integer.valueOf(parts[1]) * 1000 * 60; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/DownloadError.java b/app/src/main/java/de/danoeh/antennapod/util/DownloadError.java new file mode 100644 index 000000000..1a64991a6 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/DownloadError.java @@ -0,0 +1,52 @@ +package de.danoeh.antennapod.util; + +import android.content.Context; +import de.danoeh.antennapod.R; + +/** Utility class for Download Errors. */ +public enum DownloadError { + SUCCESS(0, R.string.download_successful), + ERROR_PARSER_EXCEPTION(1, R.string.download_error_parser_exception), + ERROR_UNSUPPORTED_TYPE(2, R.string.download_error_unsupported_type), + ERROR_CONNECTION_ERROR(3, R.string.download_error_connection_error), + ERROR_MALFORMED_URL(4, R.string.download_error_error_unknown), + ERROR_IO_ERROR(5, R.string.download_error_io_error), + ERROR_FILE_EXISTS(6, R.string.download_error_error_unknown), + ERROR_DOWNLOAD_CANCELLED(7, R.string.download_error_error_unknown), + ERROR_DEVICE_NOT_FOUND(8, R.string.download_error_device_not_found), + ERROR_HTTP_DATA_ERROR(9, R.string.download_error_http_data_error), + ERROR_NOT_ENOUGH_SPACE(10, R.string.download_error_insufficient_space), + ERROR_UNKNOWN_HOST(11, R.string.download_error_unknown_host), + ERROR_REQUEST_ERROR(12, R.string.download_error_request_error), + ERROR_DB_ACCESS_ERROR(13, R.string.download_error_db_access), + ERROR_UNAUTHORIZED(14, R.string.download_error_unauthorized); + + private final int code; + private final int resId; + + private DownloadError(int code, int resId) { + this.code = code; + this.resId = resId; + } + + /** Return DownloadError from its associated code. */ + public static DownloadError fromCode(int code) { + for (DownloadError reason : values()) { + if (reason.getCode() == code) { + return reason; + } + } + throw new IllegalArgumentException("unknown code: " + code); + } + + /** Get machine-readable code. */ + public int getCode() { + return code; + } + + /** Get a human-readable string. */ + public String getErrorString(Context context) { + return context.getString(resId); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/DuckType.java b/app/src/main/java/de/danoeh/antennapod/util/DuckType.java new file mode 100644 index 000000000..163110418 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/DuckType.java @@ -0,0 +1,117 @@ +/* Adapted from: http://thinking-in-code.blogspot.com/2008/11/duck-typing-in-java-using-dynamic.html */ + +package de.danoeh.antennapod.util; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import de.danoeh.antennapod.BuildConfig; + +/** + * Allows "duck typing" or dynamic invocation based on method signature rather + * than type hierarchy. In other words, rather than checking whether something + * IS-a duck, check whether it WALKS-like-a duck or QUACKS-like a duck. + * + * To use first use the coerce static method to indicate the object you want to + * do Duck Typing for, then specify an interface to the to method which you want + * to coerce the type to, e.g: + * + * public interface Foo { void aMethod(); } class Bar { ... public void + * aMethod() { ... } ... } Bar bar = ...; Foo foo = + * DuckType.coerce(bar).to(Foo.class); foo.aMethod(); + * + * + */ +public class DuckType { + + private final Object objectToCoerce; + + private DuckType(Object objectToCoerce) { + this.objectToCoerce = objectToCoerce; + } + + private class CoercedProxy implements InvocationHandler { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Method delegateMethod = findMethodBySignature(method); + assert delegateMethod != null; + return delegateMethod.invoke(DuckType.this.objectToCoerce, args); + } + } + + /** + * Specify the duck typed object to coerce. + * + * @param object + * the object to coerce + * @return + */ + public static DuckType coerce(Object object) { + return new DuckType(object); + } + + /** + * Coerce the Duck Typed object to the given interface providing it + * implements all the necessary methods. + * + * @param + * @param iface + * @return an instance of the given interface that wraps the duck typed + * class + * @throws ClassCastException + * if the object being coerced does not implement all the + * methods in the given interface. + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public <T> T to(Class iface) { + if (BuildConfig.DEBUG && !iface.isInterface()) throw new AssertionError("cannot coerce object to a class, must be an interface"); + if (isA(iface)) { + return (T) iface.cast(objectToCoerce); + } + if (quacksLikeA(iface)) { + return generateProxy(iface); + } + throw new ClassCastException("Could not coerce object of type " + objectToCoerce.getClass() + " to " + iface); + } + + @SuppressWarnings("rawtypes") + private boolean isA(Class iface) { + return objectToCoerce.getClass().isInstance(iface); + } + + /** + * Determine whether the duck typed object can be used with the given + * interface. + * + * @param Type + * of the interface to check. + * @param iface + * Interface class to check + * @return true if the object will support all the methods in the interface, + * false otherwise. + */ + @SuppressWarnings("rawtypes") + public boolean quacksLikeA(Class iface) { + for (Method method : iface.getMethods()) { + if (findMethodBySignature(method) == null) { + return false; + } + } + return true; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private <T> T generateProxy(Class iface) { + return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class[] { iface }, new CoercedProxy()); + } + + private Method findMethodBySignature(Method method) { + try { + return objectToCoerce.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + return null; + } + } + +}
\ No newline at end of file diff --git a/app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java b/app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java new file mode 100644 index 000000000..115913bca --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java @@ -0,0 +1,49 @@ +package de.danoeh.antennapod.util; + +import de.danoeh.antennapod.feed.FeedItem; + +import java.util.ArrayList; +import java.util.List; + +public class EpisodeFilter { + private EpisodeFilter() { + + } + + /** Return a copy of the itemlist without items which have no media. */ + public static ArrayList<FeedItem> getEpisodeList(List<FeedItem> items) { + ArrayList<FeedItem> episodes = new ArrayList<FeedItem>(items); + for (FeedItem item : items) { + if (item.getMedia() == null) { + episodes.remove(item); + } + } + return episodes; + } + + public static int countItemsWithEpisodes(List<FeedItem> items) { + int count = 0; + for (FeedItem item : items) { + if (item.getMedia() != null) { + count++; + } + } + return count; + } + + public static FeedItem accessEpisodeByIndex(List<FeedItem> items, + int position) { + int count = 0; + for (FeedItem item : items) { + + if (item.getMedia() != null) { + if (count == position) { + return item; + } else { + count++; + } + } + } + return null; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java b/app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java new file mode 100644 index 000000000..112b6678d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java @@ -0,0 +1,15 @@ +package de.danoeh.antennapod.util; + +import de.danoeh.antennapod.feed.Feed; + +import java.util.Comparator; + +/** Compares the title of two feeds for sorting. */ +public class FeedtitleComparator implements Comparator<Feed> { + + @Override + public int compare(Feed lhs, Feed rhs) { + return lhs.getTitle().compareToIgnoreCase(rhs.getTitle()); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java b/app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java new file mode 100644 index 000000000..702df62b8 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java @@ -0,0 +1,36 @@ +package de.danoeh.antennapod.util; + +import java.util.Arrays; + +/** Generates valid filenames for a given string. */ +public class FileNameGenerator { + + private static final char[] ILLEGAL_CHARACTERS = { '/', '\\', '?', '%', + '*', ':', '|', '"', '<', '>' }; + static { + Arrays.sort(ILLEGAL_CHARACTERS); + } + + private FileNameGenerator() { + + } + + /** + * This method will return a new string that doesn't contain any illegal + * characters of the given string. + */ + public static String generateFileName(String string) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < string.length(); i++) { + char c = string.charAt(i); + if (Arrays.binarySearch(ILLEGAL_CHARACTERS, c) < 0) { + builder.append(c); + } + } + return builder.toString().replaceFirst(" *$",""); + } + + public static long generateLong(final String str) { + return str.hashCode(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java b/app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java new file mode 100644 index 000000000..50adae216 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.util; + +/** Thrown if a feed has invalid attribute values. */ +public class InvalidFeedException extends Exception { + + public InvalidFeedException() { + } + + public InvalidFeedException(String detailMessage) { + super(detailMessage); + } + + public InvalidFeedException(Throwable throwable) { + super(throwable); + } + + public InvalidFeedException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/LangUtils.java b/app/src/main/java/de/danoeh/antennapod/util/LangUtils.java new file mode 100644 index 000000000..e6e1d8399 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/LangUtils.java @@ -0,0 +1,120 @@ +package de.danoeh.antennapod.util; + +import java.nio.charset.Charset; +import java.util.HashMap; + +public class LangUtils { + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + private static HashMap<String, String> languages; + static { + languages = new HashMap<String, String>(); + languages.put("af", "Afrikaans"); + languages.put("sq", "Albanian"); + languages.put("sq", "Albanian"); + languages.put("eu", "Basque"); + languages.put("be", "Belarusian"); + languages.put("bg", "Bulgarian"); + languages.put("ca", "Catalan"); + languages.put("Chinese (Simplified)", "zh-cn"); + languages.put("Chinese (Traditional)", "zh-tw"); + languages.put("hr", "Croatian"); + languages.put("cs", "Czech"); + languages.put("da", "Danish"); + languages.put("nl", "Dutch"); + languages.put("nl-be", "Dutch (Belgium)"); + languages.put("nl-nl", "Dutch (Netherlands)"); + languages.put("en", "English"); + languages.put("en-au", "English (Australia)"); + languages.put("en-bz", "English (Belize)"); + languages.put("en-ca", "English (Canada)"); + languages.put("en-ie", "English (Ireland)"); + languages.put("en-jm", "English (Jamaica)"); + languages.put("en-nz", "English (New Zealand)"); + languages.put("en-ph", "English (Phillipines)"); + languages.put("en-za", "English (South Africa)"); + languages.put("en-tt", "English (Trinidad)"); + languages.put("en-gb", "English (United Kingdom)"); + languages.put("en-us", "English (United States)"); + languages.put("en-zw", "English (Zimbabwe)"); + languages.put("et", "Estonian"); + languages.put("fo", "Faeroese"); + languages.put("fi", "Finnish"); + languages.put("fr", "French"); + languages.put("fr-be", "French (Belgium)"); + languages.put("fr-ca", "French (Canada)"); + languages.put("fr-fr", "French (France)"); + languages.put("fr-lu", "French (Luxembourg)"); + languages.put("fr-mc", "French (Monaco)"); + languages.put("fr-ch", "French (Switzerland)"); + languages.put("gl", "Galician"); + languages.put("gd", "Gaelic"); + languages.put("de", "German"); + languages.put("de-at", "German (Austria)"); + languages.put("de-de", "German (Germany)"); + languages.put("de-li", "German (Liechtenstein)"); + languages.put("de-lu", "German (Luxembourg)"); + languages.put("de-ch", "German (Switzerland)"); + languages.put("el", "Greek"); + languages.put("haw", "Hawaiian"); + languages.put("hu", "Hungarian"); + languages.put("is", "Icelandic"); + languages.put("in", "Indonesian"); + languages.put("ga", "Irish"); + languages.put("it", "Italian"); + languages.put("it-it", "Italian (Italy)"); + languages.put("it-ch", "Italian (Switzerland)"); + languages.put("ja", "Japanese"); + languages.put("ko", "Korean"); + languages.put("mk", "Macedonian"); + languages.put("no", "Norwegian"); + languages.put("pl", "Polish"); + languages.put("pt", "Portugese"); + languages.put("pt-br", "Portugese (Brazil)"); + languages.put("pt-pt", "Portugese (Portugal"); + languages.put("ro", "Romanian"); + languages.put("ro-mo", "Romanian (Moldova)"); + languages.put("ro-ro", "Romanian (Romania"); + languages.put("ru", "Russian"); + languages.put("ru-mo", "Russian (Moldova)"); + languages.put("ru-ru", "Russian (Russia)"); + languages.put("sr", "Serbian"); + languages.put("sk", "Slovak"); + languages.put("sl", "Slovenian"); + languages.put("es", "Spanish"); + languages.put("es-ar", "Spanish (Argentinia)"); + languages.put("es=bo", "Spanish (Bolivia)"); + languages.put("es-cl", "Spanish (Chile)"); + languages.put("es-co", "Spanish (Colombia)"); + languages.put("es-cr", "Spanish (Costa Rica)"); + languages.put("es-do", "Spanish (Dominican Republic)"); + languages.put("es-ec", "Spanish (Ecuador)"); + languages.put("es-sv", "Spanish (El Salvador)"); + languages.put("es-gt", "Spanish (Guatemala)"); + languages.put("es-hn", "Spanish (Honduras)"); + languages.put("es-mx", "Spanish (Mexico)"); + languages.put("es-ni", "Spanish (Nicaragua)"); + languages.put("es-pa", "Spanish (Panama)"); + languages.put("es-py", "Spanish (Paraguay)"); + languages.put("es-pe", "Spanish (Peru)"); + languages.put("es-pr", "Spanish (Puerto Rico)"); + languages.put("es-es", "Spanish (Spain)"); + languages.put("es-uy", "Spanish (Uruguay)"); + languages.put("es-ve", "Spanish (Venezuela)"); + languages.put("sv", "Swedish"); + languages.put("sv-fi", "Swedish (Finland)"); + languages.put("sv-se", "Swedish (Sweden)"); + languages.put("tr", "Turkish"); + languages.put("uk", "Ukranian"); + } + + /** Finds language string for key or returns the language key if it can't be found. */ + public static String getLanguageString(String key) { + String language = languages.get(key); + if (language != null) { + return language; + } else { + return key; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java b/app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java new file mode 100644 index 000000000..0c8065e94 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.util; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.preferences.UserPreferences; + +import java.util.Arrays; +import java.util.List; + +public class NetworkUtils { + private static final String TAG = "NetworkUtils"; + + private NetworkUtils() { + + } + + /** + * Returns true if the device is connected to Wi-Fi and the Wi-Fi filter for + * automatic downloads is disabled or the device is connected to a Wi-Fi + * network that is on the 'selected networks' list of the Wi-Fi filter for + * automatic downloads and false otherwise. + * */ + public static boolean autodownloadNetworkAvailable(Context context) { + ConnectivityManager cm = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = cm.getActiveNetworkInfo(); + if (networkInfo != null) { + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Device is connected to Wi-Fi"); + if (networkInfo.isConnected()) { + if (!UserPreferences.isEnableAutodownloadWifiFilter()) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Auto-dl filter is disabled"); + return true; + } else { + WifiManager wm = (WifiManager) context + .getSystemService(Context.WIFI_SERVICE); + WifiInfo wifiInfo = wm.getConnectionInfo(); + List<String> selectedNetworks = Arrays + .asList(UserPreferences + .getAutodownloadSelectedNetworks()); + if (selectedNetworks.contains(Integer.toString(wifiInfo + .getNetworkId()))) { + if (BuildConfig.DEBUG) + Log.d(TAG, + "Current network is on the selected networks list"); + return true; + } + } + } + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Network for auto-dl is not available"); + return false; + } + + public static boolean networkAvailable(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + return info != null && info.isConnected(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java b/app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java new file mode 100644 index 000000000..7a1c7fef2 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java @@ -0,0 +1,93 @@ +package de.danoeh.antennapod.util; + +import de.danoeh.antennapod.feed.FeedItem; + +import java.util.Iterator; +import java.util.List; + +/** + * Provides methods for accessing the queue. It is possible to load only a part of the information about the queue that + * is stored in the database (e.g. sometimes the user just has to test if a specific item is contained in the List. + * QueueAccess provides an interface for accessing the queue without having to care about the type of the queue + * representation. + */ +public abstract class QueueAccess { + /** + * Returns true if the item is in the queue, false otherwise. + */ + public abstract boolean contains(long id); + + /** + * Removes the item from the queue. + * + * @return true if the queue was modified by this operation. + */ + public abstract boolean remove(long id); + + private QueueAccess() { + + } + + public static QueueAccess IDListAccess(final List<Long> ids) { + return new QueueAccess() { + @Override + public boolean contains(long id) { + return (ids != null) && ids.contains(id); + } + + @Override + public boolean remove(long id) { + return ids.remove(id); + } + + + }; + } + + public static QueueAccess ItemListAccess(final List<FeedItem> items) { + return new QueueAccess() { + @Override + public boolean contains(long id) { + if (items == null) { + return false; + } + for (FeedItem item : items) { + if (item.getId() == id) { + return true; + } + } + return false; + } + + @Override + public boolean remove(long id) { + Iterator<FeedItem> it = items.iterator(); + FeedItem item; + while (it.hasNext()) { + item = it.next(); + if (item.getId() == id) { + it.remove(); + return true; + } + } + return false; + } + }; + } + + public static QueueAccess NotInQueueAccess() { + return new QueueAccess() { + @Override + public boolean contains(long id) { + return false; + } + + @Override + public boolean remove(long id) { + return false; + } + }; + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java b/app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java new file mode 100644 index 000000000..941fc62ae --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.util; + +import android.content.Context; +import android.content.Intent; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.feed.FeedItem; + +/** Utility methods for sharing data */ +public class ShareUtils { + private static final String TAG = "ShareUtils"; + + private ShareUtils() {} + + public static void shareLink(Context context, String link) { + Intent i = new Intent(Intent.ACTION_SEND); + i.setType("text/plain"); + i.putExtra(Intent.EXTRA_SUBJECT, "Sharing URL"); + i.putExtra(Intent.EXTRA_TEXT, link); + context.startActivity(Intent.createChooser(i, "Share URL")); + } + + public static void shareFeedItemLink(Context context, FeedItem item) { + shareLink(context, item.getLink()); + } + + public static void shareFeedDownloadLink(Context context, Feed feed) { + shareLink(context, feed.getDownload_url()); + } + + public static void shareFeedlink(Context context, Feed feed) { + shareLink(context, feed.getLink()); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java b/app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java new file mode 100644 index 000000000..8345ca34d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java @@ -0,0 +1,16 @@ +package de.danoeh.antennapod.util; + +import java.util.concurrent.Callable; + +/** + * Created by daniel on 04.08.13. + */ +public interface ShownotesProvider { + /** + * Loads shownotes. If the shownotes have to be loaded from a file or from a + * database, it should be done in a separate thread. After the shownotes + * have been loaded, callback.onShownotesLoaded should be called. + */ + public Callable<String> loadShownotes(); + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java b/app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java new file mode 100644 index 000000000..ff0bde280 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java @@ -0,0 +1,66 @@ +package de.danoeh.antennapod.util; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.StatFs; +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.activity.StorageErrorActivity; +import de.danoeh.antennapod.preferences.UserPreferences; + +import java.io.File; + +/** Utility functions for handling storage errors */ +public class StorageUtils { + private static final String TAG = "StorageUtils"; + + public static boolean storageAvailable(Context context) { + File dir = UserPreferences.getDataFolder(context, null); + if (dir != null) { + return dir.exists() && dir.canRead() && dir.canWrite(); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Storage not available: data folder is null"); + return false; + } + } + + /** + * Checks if external storage is available. If external storage isn't + * available, the current activity is finsished an an error activity is + * launched. + * + * @param activity + * the activity which would be finished if no storage is + * available + * @return true if external storage is available + */ + public static boolean checkStorageAvailability(Activity activity) { + boolean storageAvailable = storageAvailable(activity); + if (!storageAvailable) { + activity.finish(); + activity.startActivity(new Intent(activity, + StorageErrorActivity.class)); + } + return storageAvailable; + } + + /** Get the number of free bytes that are available on the external storage. */ + public static long getFreeSpaceAvailable() { + StatFs stat = new StatFs(UserPreferences.getDataFolder( + PodcastApp.getInstance(), null).getAbsolutePath()); + long availableBlocks; + long blockSize; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + availableBlocks = stat.getAvailableBlocksLong(); + blockSize = stat.getBlockSizeLong(); + } else { + availableBlocks = stat.getAvailableBlocks(); + blockSize = stat.getBlockSize(); + } + return availableBlocks * blockSize; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java b/app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java new file mode 100644 index 000000000..8e593f3fb --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java @@ -0,0 +1,22 @@ +package de.danoeh.antennapod.util; + +import android.util.Log; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.preferences.UserPreferences; + +public class ThemeUtils { + private static final String TAG = "ThemeUtils"; + + public static int getSelectionBackgroundColor() { + switch (UserPreferences.getTheme()) { + case R.style.Theme_AntennaPod_Dark: + return R.color.selection_background_color_dark; + case R.style.Theme_AntennaPod_Light: + return R.color.selection_background_color_light; + default: + Log.e(TAG, + "getSelectionBackgroundColor could not match the current theme to any color!"); + return R.color.selection_background_color_light; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/URIUtil.java b/app/src/main/java/de/danoeh/antennapod/util/URIUtil.java new file mode 100644 index 000000000..5af40d591 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/URIUtil.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.util; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +/** + * Utility methods for dealing with URL encoding. + */ +public class URIUtil { + private static final String TAG = "URIUtil"; + + private URIUtil() {} + + public static URI getURIFromRequestUrl(String source) { + // try without encoding the URI + try { + return new URI(source); + } catch (URISyntaxException e) { + if (BuildConfig.DEBUG) Log.d(TAG, "Source is not encoded, encoding now"); + } + try { + URL url = new URL(source); + return new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef()); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/URLChecker.java b/app/src/main/java/de/danoeh/antennapod/util/URLChecker.java new file mode 100644 index 000000000..9997daaf7 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/URLChecker.java @@ -0,0 +1,51 @@ +package de.danoeh.antennapod.util; + +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.BuildConfig; + +/** + * Provides methods for checking and editing a URL. + */ +public final class URLChecker { + + /** + * Class shall not be instantiated. + */ + private URLChecker() { + } + + /** + * Logging tag. + */ + private static final String TAG = "URLChecker"; + + /** + * Checks if URL is valid and modifies it if necessary. + * + * @param url The url which is going to be prepared + * @return The prepared url + */ + public static String prepareURL(String url) { + StringBuilder builder = new StringBuilder(); + url = StringUtils.trim(url); + if (url.startsWith("feed://")) { + if (BuildConfig.DEBUG) Log.d(TAG, "Replacing feed:// with http://"); + url = url.replaceFirst("feed://", "http://"); + } else if (url.startsWith("pcast://")) { + if (BuildConfig.DEBUG) Log.d(TAG, "Replacing pcast:// with http://"); + url = url.replaceFirst("pcast://", "http://"); + } else if (url.startsWith("itpc")) { + if (BuildConfig.DEBUG) Log.d(TAG, "Replacing itpc:// with http://"); + url = url.replaceFirst("itpc://", "http://"); + } else if (!(url.startsWith("http://") || url.startsWith("https://"))) { + if (BuildConfig.DEBUG) Log.d(TAG, "Adding http:// at the beginning of the URL"); + builder.append("http://"); + } + builder.append(url); + + return builder.toString(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java b/app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java new file mode 100644 index 000000000..332cc22e0 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012 Roman Nurik + * + * 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 de.danoeh.antennapod.util; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Parcelable; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; +import com.nineoldandroids.animation.Animator; +import com.nineoldandroids.animation.AnimatorListenerAdapter; +import com.nineoldandroids.view.ViewHelper; +import com.nineoldandroids.view.ViewPropertyAnimator; +import de.danoeh.antennapod.R; + +import static com.nineoldandroids.view.ViewPropertyAnimator.animate; + +public class UndoBarController { + private View mBarView; + private TextView mMessageView; + private ViewPropertyAnimator mBarAnimator; + private Handler mHideHandler = new Handler(); + + private UndoListener mUndoListener; + + // State objects + private Parcelable mUndoToken; + private CharSequence mUndoMessage; + + public interface UndoListener { + void onUndo(Parcelable token); + } + + public UndoBarController(View undoBarView, UndoListener undoListener) { + mBarView = undoBarView; + mBarAnimator = animate(mBarView); + mUndoListener = undoListener; + + mMessageView = (TextView) mBarView.findViewById(R.id.undobar_message); + mBarView.findViewById(R.id.undobar_button) + .setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + hideUndoBar(false); + mUndoListener.onUndo(mUndoToken); + } + }); + + hideUndoBar(true); + } + + public void showUndoBar(boolean immediate, CharSequence message, Parcelable undoToken) { + mUndoToken = undoToken; + mUndoMessage = message; + mMessageView.setText(mUndoMessage); + + mHideHandler.removeCallbacks(mHideRunnable); + mHideHandler.postDelayed(mHideRunnable, + mBarView.getResources().getInteger(R.integer.undobar_hide_delay)); + + mBarView.setVisibility(View.VISIBLE); + if (immediate) { + ViewHelper.setAlpha(mBarView, 1); + } else { + mBarAnimator.cancel(); + mBarAnimator + .alpha(1) + .setDuration( + mBarView.getResources() + .getInteger(android.R.integer.config_shortAnimTime)) + .setListener(null); + } + } + + public void hideUndoBar(boolean immediate) { + mHideHandler.removeCallbacks(mHideRunnable); + if (immediate) { + mBarView.setVisibility(View.GONE); + ViewHelper.setAlpha(mBarView, 0); + mUndoMessage = null; + mUndoToken = null; + + } else { + mBarAnimator.cancel(); + mBarAnimator + .alpha(0) + .setDuration(mBarView.getResources() + .getInteger(android.R.integer.config_shortAnimTime)) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mBarView.setVisibility(View.GONE); + mUndoMessage = null; + mUndoToken = null; + } + }); + } + } + + public void onSaveInstanceState(Bundle outState) { + outState.putCharSequence("undo_message", mUndoMessage); + outState.putParcelable("undo_token", mUndoToken); + } + + public void onRestoreInstanceState(Bundle savedInstanceState) { + if (savedInstanceState != null) { + mUndoMessage = savedInstanceState.getCharSequence("undo_message"); + mUndoToken = savedInstanceState.getParcelable("undo_token"); + + if (mUndoToken != null || !TextUtils.isEmpty(mUndoMessage)) { + showUndoBar(true, mUndoMessage, mUndoToken); + } + } + } + + private Runnable mHideRunnable = new Runnable() { + @Override + public void run() { + hideUndoBar(false); + } + }; +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java new file mode 100644 index 000000000..bfc2fd057 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.util.comparator; + +import de.danoeh.antennapod.feed.Chapter; + +import java.util.Comparator; + +public class ChapterStartTimeComparator implements Comparator<Chapter> { + + @Override + public int compare(Chapter lhs, Chapter rhs) { + if (lhs.getStart() == rhs.getStart()) { + return 0; + } else if (lhs.getStart() < rhs.getStart()) { + return -1; + } else { + return 1; + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java new file mode 100644 index 000000000..14b8f1194 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java @@ -0,0 +1,15 @@ +package de.danoeh.antennapod.util.comparator; + +import de.danoeh.antennapod.service.download.DownloadStatus; + +import java.util.Comparator; + +/** Compares the completion date of two Downloadstatus objects. */ +public class DownloadStatusComparator implements Comparator<DownloadStatus> { + + @Override + public int compare(DownloadStatus lhs, DownloadStatus rhs) { + return rhs.getCompletionDate().compareTo(lhs.getCompletionDate()); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java new file mode 100644 index 000000000..f92c23d05 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.util.comparator; + +import de.danoeh.antennapod.feed.FeedItem; + +import java.util.Comparator; + +/** Compares the pubDate of two FeedItems for sorting*/ +public class FeedItemPubdateComparator implements Comparator<FeedItem> { + + /** Returns a new instance of this comparator in reverse order. + public static FeedItemPubdateComparator newInstance() { + FeedItemPubdateComparator + }*/ + @Override + public int compare(FeedItem lhs, FeedItem rhs) { + return rhs.getPubDate().compareTo(lhs.getPubDate()); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java new file mode 100644 index 000000000..0147e0cdc --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.util.comparator; + +import de.danoeh.antennapod.feed.FeedItem; + +import java.util.Comparator; + +public class PlaybackCompletionDateComparator implements Comparator<FeedItem> { + + public int compare(FeedItem lhs, FeedItem rhs) { + if (lhs.getMedia() != null + && lhs.getMedia().getPlaybackCompletionDate() != null + && rhs.getMedia() != null + && rhs.getMedia().getPlaybackCompletionDate() != null) { + return rhs.getMedia().getPlaybackCompletionDate() + .compareTo(lhs.getMedia().getPlaybackCompletionDate()); + } + return 0; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java new file mode 100644 index 000000000..02b084a01 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java @@ -0,0 +1,14 @@ +package de.danoeh.antennapod.util.comparator; + +import de.danoeh.antennapod.feed.SearchResult; + +import java.util.Comparator; + +public class SearchResultValueComparator implements Comparator<SearchResult> { + + @Override + public int compare(SearchResult lhs, SearchResult rhs) { + return rhs.getValue() - lhs.getValue(); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java b/app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java new file mode 100644 index 000000000..4586cea87 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java @@ -0,0 +1,23 @@ +package de.danoeh.antennapod.util.exception; + +import de.danoeh.antennapod.feed.FeedMedia; + +public class MediaFileNotFoundException extends Exception { + private static final long serialVersionUID = 1L; + + private FeedMedia media; + + public MediaFileNotFoundException(String msg, FeedMedia media) { + super(msg); + this.media = media; + } + + public MediaFileNotFoundException(FeedMedia media) { + super(); + this.media = media; + } + + public FeedMedia getMedia() { + return media; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java new file mode 100644 index 000000000..eda83b7aa --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.util.flattr; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import org.shredzone.flattr4j.FlattrFactory; +import org.shredzone.flattr4j.FlattrService; +import org.shredzone.flattr4j.oauth.AccessToken; + +/** Ensures that only one instance of the FlattrService class exists at a time */ + +public class FlattrServiceCreator { + public static final String TAG = "FlattrServiceCreator"; + + private static volatile FlattrService flattrService; + + public static FlattrService getService(AccessToken token) { + return FlattrFactory.getInstance().createFlattrService(token); + } + + public static void deleteFlattrService() { + if (BuildConfig.DEBUG) Log.d(TAG, "Deleting service instance"); + flattrService = null; + } +} + diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java new file mode 100644 index 000000000..a1d6d3bc4 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java @@ -0,0 +1,68 @@ +package de.danoeh.antennapod.util.flattr; + +import java.util.Calendar; + +public class FlattrStatus { + public static final int STATUS_UNFLATTERED = 0; + public static final int STATUS_QUEUE = 1; + public static final int STATUS_FLATTRED = 2; + + private int status = STATUS_UNFLATTERED; + private Calendar lastFlattred; + + public FlattrStatus() { + status = STATUS_UNFLATTERED; + lastFlattred = Calendar.getInstance(); + } + + public FlattrStatus(long status) { + lastFlattred = Calendar.getInstance(); + fromLong(status); + } + + public void setFlattred() { + status = STATUS_FLATTRED; + lastFlattred = Calendar.getInstance(); + } + + public void setUnflattred() { + status = STATUS_UNFLATTERED; + } + + public boolean getUnflattred() { + return status == STATUS_UNFLATTERED; + } + + public void setFlattrQueue() { + if (flattrable()) + status = STATUS_QUEUE; + } + + public void fromLong(long status) { + if (status == STATUS_UNFLATTERED || status == STATUS_QUEUE) + this.status = (int) status; + else { + this.status = STATUS_FLATTRED; + lastFlattred.setTimeInMillis(status); + } + } + + public long toLong() { + if (status == STATUS_UNFLATTERED || status == STATUS_QUEUE) + return status; + else { + return lastFlattred.getTimeInMillis(); + } + } + + public boolean flattrable() { + Calendar firstOfMonth = Calendar.getInstance(); + firstOfMonth.set(Calendar.DAY_OF_MONTH, Calendar.getInstance().getActualMinimum(Calendar.DAY_OF_MONTH)); + + return (status == STATUS_UNFLATTERED) || (status == STATUS_FLATTRED && firstOfMonth.after(lastFlattred) ); + } + + public boolean getFlattrQueue() { + return status == STATUS_QUEUE; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java new file mode 100644 index 000000000..f17ef1d83 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java @@ -0,0 +1,7 @@ +package de.danoeh.antennapod.util.flattr; + +public interface FlattrThing { + public String getTitle(); + public String getPaymentLink(); + public FlattrStatus getFlattrStatus(); +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java new file mode 100644 index 000000000..3e2ea3132 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java @@ -0,0 +1,305 @@ +package de.danoeh.antennapod.util.flattr; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; +import org.shredzone.flattr4j.FlattrService; +import org.shredzone.flattr4j.exception.FlattrException; +import org.shredzone.flattr4j.model.Flattr; +import org.shredzone.flattr4j.model.Thing; +import org.shredzone.flattr4j.oauth.AccessToken; +import org.shredzone.flattr4j.oauth.AndroidAuthenticator; +import org.shredzone.flattr4j.oauth.Scope; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.TimeZone; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.PodcastApp; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.FlattrAuthActivity; +import de.danoeh.antennapod.asynctask.FlattrTokenFetcher; +import de.danoeh.antennapod.storage.DBWriter; + +/** + * Utility methods for doing something with flattr. + */ + +public class FlattrUtils { + private static final String TAG = "FlattrUtils"; + + private static final String HOST_NAME = "de.danoeh.antennapod"; + + private static final String PREF_ACCESS_TOKEN = "de.danoeh.antennapod.preference.flattrAccessToken"; + + // Flattr URL for this app. + public static final String APP_URL = "http://antennapod.com"; + // Human-readable flattr-page. + public static final String APP_LINK = "https://flattr.com/thing/745609/"; + public static final String APP_THING_ID = "745609"; + + private static volatile AccessToken cachedToken; + + private static AndroidAuthenticator createAuthenticator() { + return new AndroidAuthenticator(HOST_NAME, BuildConfig.FLATTR_APP_KEY, + BuildConfig.FLATTR_APP_SECRET); + } + + public static void startAuthProcess(Context context) throws FlattrException { + AndroidAuthenticator auth = createAuthenticator(); + auth.setScope(EnumSet.of(Scope.FLATTR)); + Intent intent = auth.createAuthenticateIntent(); + context.startActivity(intent); + } + + private static AccessToken retrieveToken() { + if (cachedToken == null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Retrieving access token"); + String token = PreferenceManager.getDefaultSharedPreferences( + PodcastApp.getInstance()) + .getString(PREF_ACCESS_TOKEN, null); + if (token != null) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Found access token. Caching."); + cachedToken = new AccessToken(token); + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "No access token found"); + return null; + } + } + return cachedToken; + + } + + /** + * Returns true if FLATTR_APP_KEY and FLATTR_APP_SECRET in BuildConfig are not null and not empty + */ + public static boolean hasAPICredentials() { + return StringUtils.isNotEmpty(BuildConfig.FLATTR_APP_KEY) + && StringUtils.isNotEmpty(BuildConfig.FLATTR_APP_SECRET); + } + + public static boolean hasToken() { + return retrieveToken() != null; + } + + public static void storeToken(AccessToken token) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Storing token"); + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(PodcastApp.getInstance()).edit(); + if (token != null) { + editor.putString(PREF_ACCESS_TOKEN, token.getToken()); + } else { + editor.putString(PREF_ACCESS_TOKEN, null); + } + editor.commit(); + cachedToken = token; + } + + public static void deleteToken() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Deleting flattr token"); + storeToken(null); + } + + public static Thing getAppThing(Context context) { + FlattrService fs = FlattrServiceCreator.getService(retrieveToken()); + try { + Thing thing = fs.getThing(Thing.withId(APP_THING_ID)); + return thing; + } catch (FlattrException e) { + e.printStackTrace(); + showErrorDialog(context, e.getMessage()); + return null; + } + } + + public static void clickUrl(Context context, String url) + throws FlattrException { + if (hasToken()) { + FlattrService fs = FlattrServiceCreator.getService(retrieveToken()); + fs.click(url); + } else { + Log.e(TAG, "clickUrl was called with null access token"); + } + } + + public static List<Flattr> retrieveFlattredThings() + throws FlattrException { + ArrayList<Flattr> myFlattrs = new ArrayList<Flattr>(); + + if (hasToken()) { + FlattrService fs = FlattrServiceCreator.getService(retrieveToken()); + + Calendar firstOfMonth = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + firstOfMonth.set(Calendar.MILLISECOND, 0); + firstOfMonth.set(Calendar.SECOND, 0); + firstOfMonth.set(Calendar.MINUTE, 0); + firstOfMonth.set(Calendar.HOUR_OF_DAY, 0); + firstOfMonth.set(Calendar.DAY_OF_MONTH, Calendar.getInstance().getActualMinimum(Calendar.DAY_OF_MONTH)); + + Date firstOfMonthDate = firstOfMonth.getTime(); + + // subscriptions some times get flattrd slightly before midnight - give it an hour leeway + firstOfMonthDate = new Date(firstOfMonthDate.getTime() - 60 * 60 * 1000); + + final int FLATTR_COUNT = 30; + final int FLATTR_MAXPAGE = 5; + + for (int page = 0; page < FLATTR_MAXPAGE; page++) { + for (Flattr fl : fs.getMyFlattrs(FLATTR_COUNT, page)) { + if (fl.getCreated().after(firstOfMonthDate)) + myFlattrs.add(fl); + else + break; + } + } + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Got my flattrs list of length " + Integer.toString(myFlattrs.size()) + " comparison date" + firstOfMonthDate); + + for (Flattr fl : myFlattrs) { + Thing thing = fl.getThing(); + Log.d(TAG, "Flattr thing: " + fl.getThingId() + " name: " + thing.getTitle() + " url: " + thing.getUrl() + " on: " + fl.getCreated()); + } + } + + } else { + Log.e(TAG, "retrieveFlattrdThings was called with null access token"); + } + + return myFlattrs; + } + + public static void handleCallback(Context context, Uri uri) { + AndroidAuthenticator auth = createAuthenticator(); + new FlattrTokenFetcher(context, auth, uri).executeAsync(); + } + + public static void revokeAccessToken(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Revoking access token"); + deleteToken(); + FlattrServiceCreator.deleteFlattrService(); + showRevokeDialog(context); + DBWriter.clearAllFlattrStatus(context); + } + + // ------------------------------------------------ DIALOGS + + public static void showRevokeDialog(final Context context) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.access_revoked_title); + builder.setMessage(R.string.access_revoked_info); + builder.setNeutralButton(android.R.string.ok, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }); + builder.create().show(); + } + + /** + * Opens a dialog that ask the user to either connect the app with flattr or to be redirected to + * the thing's website. + * If no API credentials are available, the user will immediately be redirected to the thing's website. + * */ + public static void showNoTokenDialogOrRedirect(final Context context, final String url) { + if (hasAPICredentials()) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.no_flattr_token_title); + builder.setMessage(R.string.no_flattr_token_msg); + builder.setPositiveButton(R.string.authenticate_now_label, + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + context.startActivity(new Intent(context, + FlattrAuthActivity.class)); + } + + } + ); + + builder.setNegativeButton(R.string.visit_website_label, + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + Uri uri = Uri.parse(url); + context.startActivity(new Intent(Intent.ACTION_VIEW, + uri)); + } + + } + ); + builder.create().show(); + } else { + Uri uri = Uri.parse(url); + context.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } + } + + public static void showForbiddenDialog(final Context context, + final String url) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.action_forbidden_title); + builder.setMessage(R.string.action_forbidden_msg); + builder.setPositiveButton(R.string.authenticate_now_label, + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + context.startActivity(new Intent(context, + FlattrAuthActivity.class)); + } + + } + ); + builder.setNegativeButton(R.string.visit_website_label, + new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + Uri uri = Uri.parse(url); + context.startActivity(new Intent(Intent.ACTION_VIEW, + uri)); + } + + } + ); + builder.create().show(); + } + + public static void showErrorDialog(final Context context, final String msg) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.error_label); + builder.setMessage(msg); + builder.setNeutralButton(android.R.string.ok, new OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }); + builder.create().show(); + } + +}
\ No newline at end of file diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java new file mode 100644 index 000000000..296610871 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java @@ -0,0 +1,30 @@ +package de.danoeh.antennapod.util.flattr; + +/* SimpleFlattrThing is a trivial implementation of the FlattrThing interface */ +public class SimpleFlattrThing implements FlattrThing { + public SimpleFlattrThing(String title, String url, FlattrStatus status) + { + this.title = title; + this.url = url; + this.status = status; + } + + public String getTitle() + { + return this.title; + } + + public String getPaymentLink() + { + return this.url; + } + + public FlattrStatus getFlattrStatus() + { + return this.status; + } + + private String title; + private String url; + private FlattrStatus status; +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java b/app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java new file mode 100644 index 000000000..b920559db --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java @@ -0,0 +1,55 @@ +package de.danoeh.antennapod.util.gui; + +import android.os.Parcel; +import android.os.Parcelable; +import de.danoeh.antennapod.feed.FeedItem; + +/** + * Used by an UndoBarController for saving a removed FeedItem + */ +public class FeedItemUndoToken implements Parcelable { + private long itemId; + private long feedId; + private int position; + + public FeedItemUndoToken(FeedItem item, int position) { + this.itemId = item.getId(); + this.feedId = item.getFeed().getId(); + this.position = position; + } + + private FeedItemUndoToken(Parcel in) { + itemId = in.readLong(); + feedId = in.readLong(); + position = in.readInt(); + } + + public static final Parcelable.Creator<FeedItemUndoToken> CREATOR = new Parcelable.Creator<FeedItemUndoToken>() { + public FeedItemUndoToken createFromParcel(Parcel in) { + return new FeedItemUndoToken(in); + } + + public FeedItemUndoToken[] newArray(int size) { + return new FeedItemUndoToken[size]; + } + }; + + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel out, int flags) { + out.writeLong(itemId); + out.writeLong(feedId); + out.writeInt(position); + } + + public long getFeedItemId() { + return itemId; + } + + public int getPosition() { + return position; + } +} + diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java new file mode 100644 index 000000000..257635129 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java @@ -0,0 +1,118 @@ +package de.danoeh.antennapod.util.id3reader; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.ID3Chapter; +import de.danoeh.antennapod.util.id3reader.model.FrameHeader; +import de.danoeh.antennapod.util.id3reader.model.TagHeader; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.List; + +public class ChapterReader extends ID3Reader { + private static final String TAG = "ID3ChapterReader"; + + private static final String FRAME_ID_CHAPTER = "CHAP"; + private static final String FRAME_ID_TITLE = "TIT2"; + private static final String FRAME_ID_LINK = "WXXX"; + + private List<Chapter> chapters; + private ID3Chapter currentChapter; + + @Override + public int onStartTagHeader(TagHeader header) { + chapters = new ArrayList<Chapter>(); + System.out.println(header.toString()); + return ID3Reader.ACTION_DONT_SKIP; + } + + @Override + public int onStartFrameHeader(FrameHeader header, InputStream input) + throws IOException, ID3ReaderException { + System.out.println(header.toString()); + if (header.getId().equals(FRAME_ID_CHAPTER)) { + if (currentChapter != null) { + if (!hasId3Chapter(currentChapter)) { + chapters.add(currentChapter); + if (BuildConfig.DEBUG) Log.d(TAG, "Found chapter: " + currentChapter); + currentChapter = null; + } + } + StringBuffer elementId = new StringBuffer(); + readISOString(elementId, input, Integer.MAX_VALUE); + char[] startTimeSource = readBytes(input, 4); + long startTime = ((int) startTimeSource[0] << 24) + | ((int) startTimeSource[1] << 16) + | ((int) startTimeSource[2] << 8) | startTimeSource[3]; + currentChapter = new ID3Chapter(elementId.toString(), startTime); + skipBytes(input, 12); + return ID3Reader.ACTION_DONT_SKIP; + } else if (header.getId().equals(FRAME_ID_TITLE)) { + if (currentChapter != null && currentChapter.getTitle() == null) { + StringBuffer title = new StringBuffer(); + readString(title, input, header.getSize()); + currentChapter + .setTitle(title.toString()); + if (BuildConfig.DEBUG) Log.d(TAG, "Found title: " + currentChapter.getTitle()); + + return ID3Reader.ACTION_DONT_SKIP; + } + } else if (header.getId().equals(FRAME_ID_LINK)) { + if (currentChapter != null) { + // skip description + int descriptionLength = readString(null, input, header.getSize()); + StringBuffer link = new StringBuffer(); + readISOString(link, input, header.getSize() - descriptionLength); + String decodedLink = URLDecoder.decode(link.toString(), "UTF-8"); + + currentChapter.setLink(decodedLink); + + if (BuildConfig.DEBUG) Log.d(TAG, "Found link: " + currentChapter.getLink()); + return ID3Reader.ACTION_DONT_SKIP; + } + } else if (header.getId().equals("APIC")) { + Log.d(TAG, header.toString()); + } + + return super.onStartFrameHeader(header, input); + } + + private boolean hasId3Chapter(ID3Chapter chapter) { + for (Chapter c : chapters) { + if (((ID3Chapter) c).getId3ID().equals(chapter.getId3ID())) { + return true; + } + } + return false; + } + + @Override + public void onEndTag() { + if (currentChapter != null) { + if (!hasId3Chapter(currentChapter)) { + chapters.add(currentChapter); + } + } + System.out.println("Reached end of tag"); + if (chapters != null) { + for (Chapter c : chapters) { + System.out.println(c.toString()); + } + } + } + + @Override + public void onNoTagHeaderFound() { + System.out.println("No tag header found"); + super.onNoTagHeaderFound(); + } + + public List<Chapter> getChapters() { + return chapters; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java new file mode 100644 index 000000000..252d64107 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java @@ -0,0 +1,250 @@ +package de.danoeh.antennapod.util.id3reader; + +import de.danoeh.antennapod.util.id3reader.model.FrameHeader; +import de.danoeh.antennapod.util.id3reader.model.TagHeader; +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; + +/** + * Reads the ID3 Tag of a given file. In order to use this class, you should + * create a subclass of it and overwrite the onStart* - or onEnd* - methods. + */ +public class ID3Reader { + private static final int HEADER_LENGTH = 10; + private static final int ID3_LENGTH = 3; + private static final int FRAME_ID_LENGTH = 4; + + protected static final int ACTION_SKIP = 1; + protected static final int ACTION_DONT_SKIP = 2; + + protected int readerPosition; + + private static final byte ENCODING_UTF16_WITH_BOM = 1; + private static final byte ENCODING_UTF16_WITHOUT_BOM = 2; + private static final byte ENCODING_UTF8 = 3; + + private TagHeader tagHeader; + + public ID3Reader() { + } + + public final void readInputStream(InputStream input) throws IOException, + ID3ReaderException { + int rc; + readerPosition = 0; + char[] tagHeaderSource = readBytes(input, HEADER_LENGTH); + tagHeader = createTagHeader(tagHeaderSource); + if (tagHeader == null) { + onNoTagHeaderFound(); + } else { + rc = onStartTagHeader(tagHeader); + if (rc == ACTION_SKIP) { + onEndTag(); + } else { + while (readerPosition < tagHeader.getSize()) { + FrameHeader frameHeader = createFrameHeader(readBytes( + input, HEADER_LENGTH)); + if (checkForNullString(frameHeader.getId())) { + break; + } else { + rc = onStartFrameHeader(frameHeader, input); + if (rc == ACTION_SKIP) { + + if (frameHeader.getSize() + readerPosition > tagHeader + .getSize()) { + break; + } else { + skipBytes(input, frameHeader.getSize()); + } + } + } + } + onEndTag(); + } + } + } + + /** Returns true if string only contains null-bytes. */ + private boolean checkForNullString(String s) { + if (!s.isEmpty()) { + int i = 0; + if (s.charAt(i) == 0) { + for (i = 1; i < s.length(); i++) { + if (s.charAt(i) != 0) { + return false; + } + } + return true; + } + return false; + } else { + return true; + } + + } + + /** + * Read a certain number of bytes from the given input stream. This method + * changes the readerPosition-attribute. + */ + protected char[] readBytes(InputStream input, int number) + throws IOException, ID3ReaderException { + char[] header = new char[number]; + for (int i = 0; i < number; i++) { + int b = input.read(); + readerPosition++; + if (b != -1) { + header[i] = (char) b; + } else { + throw new ID3ReaderException("Unexpected end of stream"); + } + } + return header; + } + + /** + * Skip a certain number of bytes on the given input stream. This method + * changes the readerPosition-attribute. + */ + protected void skipBytes(InputStream input, int number) throws IOException { + if (number <= 0) { + number = 1; + } + IOUtils.skipFully(input, number); + + readerPosition += number; + } + + private TagHeader createTagHeader(char[] source) throws ID3ReaderException { + boolean hasTag = (source[0] == 0x49) && (source[1] == 0x44) + && (source[2] == 0x33); + if (source.length != HEADER_LENGTH) { + throw new ID3ReaderException("Length of header must be " + + HEADER_LENGTH); + } + if (hasTag) { + String id = new String(source, 0, ID3_LENGTH); + char version = (char) ((source[3] << 8) | source[4]); + byte flags = (byte) source[5]; + int size = (source[6] << 24) | (source[7] << 16) | (source[8] << 8) + | source[9]; + size = unsynchsafe(size); + return new TagHeader(id, size, version, flags); + } else { + return null; + } + } + + private FrameHeader createFrameHeader(char[] source) + throws ID3ReaderException { + if (source.length != HEADER_LENGTH) { + throw new ID3ReaderException("Length of header must be " + + HEADER_LENGTH); + } + String id = new String(source, 0, FRAME_ID_LENGTH); + + int size = (((int) source[4]) << 24) | (((int) source[5]) << 16) + | (((int) source[6]) << 8) | source[7]; + if (tagHeader != null && tagHeader.getVersion() >= 0x0400) { + size = unsynchsafe(size); + } + char flags = (char) ((source[8] << 8) | source[9]); + return new FrameHeader(id, size, flags); + } + + private int unsynchsafe(int in) { + int out = 0; + int mask = 0x7F000000; + + while (mask != 0) { + out >>= 1; + out |= in & mask; + mask >>= 8; + } + + return out; + } + + protected int readString(StringBuffer buffer, InputStream input, int max) throws IOException, + ID3ReaderException { + if (max > 0) { + char[] encoding = readBytes(input, 1); + max--; + + if (encoding[0] == ENCODING_UTF16_WITH_BOM || encoding[0] == ENCODING_UTF16_WITHOUT_BOM) { + return readUnicodeString(buffer, input, max, Charset.forName("UTF-16")) + 1; // take encoding byte into account + } else if (encoding[0] == ENCODING_UTF8) { + return readUnicodeString(buffer, input, max, Charset.forName("UTF-8")) + 1; // take encoding byte into account + } else { + return readISOString(buffer, input, max) + 1; // take encoding byte into account + } + } else { + if (buffer != null) { + buffer.append(""); + } + return 0; + } + } + + protected int readISOString(StringBuffer buffer, InputStream input, int max) + throws IOException, ID3ReaderException { + + int bytesRead = 0; + char c; + while (++bytesRead <= max && (c = (char) input.read()) > 0) { + if (buffer != null) { + buffer.append(c); + } + } + return bytesRead; + } + + private int readUnicodeString(StringBuffer strBuffer, InputStream input, int max, Charset charset) + throws IOException, ID3ReaderException { + byte[] buffer = new byte[max]; + int c, cZero = -1; + int i = 0; + for (; i < max; i++) { + c = input.read(); + if (c == -1) { + break; + } else if (c == 0) { + if (cZero == 0) { + // termination character found + break; + } else { + cZero = 0; + } + } else { + buffer[i] = (byte) c; + cZero = -1; + } + } + if (strBuffer != null) { + strBuffer.append(charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString()); + } + return i; + } + + public int onStartTagHeader(TagHeader header) { + return ACTION_SKIP; + } + + public int onStartFrameHeader(FrameHeader header, InputStream input) + throws IOException, ID3ReaderException { + return ACTION_SKIP; + } + + public void onEndTag() { + + } + + public void onNoTagHeaderFound() { + + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java new file mode 100644 index 000000000..c458540ee --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.util.id3reader; + +public class ID3ReaderException extends Exception { + + public ID3ReaderException() { + } + + public ID3ReaderException(String arg0) { + super(arg0); + } + + public ID3ReaderException(Throwable arg0) { + super(arg0); + } + + public ID3ReaderException(String arg0, Throwable arg1) { + super(arg0, arg1); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java new file mode 100644 index 000000000..df73393a5 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java @@ -0,0 +1,17 @@ +package de.danoeh.antennapod.util.id3reader.model; + +public class FrameHeader extends Header { + + protected char flags; + + public FrameHeader(String id, int size, char flags) { + super(id, size); + this.flags = flags; + } + + @Override + public String toString() { + return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, Integer.toBinaryString(size)); + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java new file mode 100644 index 000000000..22d5b6376 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java @@ -0,0 +1,29 @@ +package de.danoeh.antennapod.util.id3reader.model; + +public abstract class Header { + + protected String id; + protected int size; + + public Header(String id, int size) { + super(); + this.id = id; + this.size = size; + } + + public String getId() { + return id; + } + + public int getSize() { + return size; + } + + @Override + public String toString() { + return "Header [id=" + id + ", size=" + size + "]"; + } + + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java new file mode 100644 index 000000000..ec99ef14e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.util.id3reader.model; + +public class TagHeader extends Header { + + protected char version; + protected byte flags; + + public TagHeader(String id, int size, char version, byte flags) { + super(id, size); + this.version = version; + this.flags = flags; + } + + @Override + public String toString() { + return "TagHeader [version=" + version + ", flags=" + flags + ", id=" + + id + ", size=" + size + "]"; + } + + public char getVersion() { + return version; + } + + + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java new file mode 100644 index 000000000..2c7a7f074 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java @@ -0,0 +1,191 @@ +package de.danoeh.antennapod.util.menuhandler; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.FeedItem; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.QueueAccess; +import de.danoeh.antennapod.util.ShareUtils; + +/** + * Handles interactions with the FeedItemMenu. + */ +public class FeedItemMenuHandler { + private static final String TAG = "FeedItemMenuHandler"; + + private FeedItemMenuHandler() { + + } + + /** + * Used by the MenuHandler to access different types of menus through one + * interface + */ + public interface MenuInterface { + /** + * Implementations of this method should call findItem(id) on their + * menu-object and call setVisibility(visibility) on the returned + * MenuItem object. + */ + abstract void setItemVisibility(int id, boolean visible); + } + + /** + * This method should be called in the prepare-methods of menus. It changes + * the visibility of the menu items depending on a FeedItem's attributes. + * + * @param mi An instance of MenuInterface that the method uses to change a + * MenuItem's visibility + * @param selectedItem The FeedItem for which the menu is supposed to be prepared + * @param showExtendedMenu True if MenuItems that let the user share information about + * the FeedItem and visit its website should be set visible. This + * parameter should be set to false if the menu space is limited. + * @param queueAccess Used for testing if the queue contains the selected item + * @return Returns true if selectedItem is not null. + */ + public static boolean onPrepareMenu(MenuInterface mi, + FeedItem selectedItem, boolean showExtendedMenu, QueueAccess queueAccess) { + if (selectedItem == null) { + return false; + } + DownloadRequester requester = DownloadRequester.getInstance(); + boolean hasMedia = selectedItem.getMedia() != null; + boolean downloaded = hasMedia && selectedItem.getMedia().isDownloaded(); + boolean downloading = hasMedia + && requester.isDownloadingFile(selectedItem.getMedia()); + boolean notLoadedAndNotLoading = hasMedia && (!downloaded) + && (!downloading); + boolean isPlaying = hasMedia + && selectedItem.getState() == FeedItem.State.PLAYING; + + FeedItem.State state = selectedItem.getState(); + + if (!isPlaying) { + mi.setItemVisibility(R.id.skip_episode_item, false); + } + if (!downloaded || isPlaying) { + mi.setItemVisibility(R.id.play_item, false); + mi.setItemVisibility(R.id.remove_item, false); + } + if (!notLoadedAndNotLoading) { + mi.setItemVisibility(R.id.download_item, false); + } + if (!(notLoadedAndNotLoading | downloading) | isPlaying) { + mi.setItemVisibility(R.id.stream_item, false); + } + if (!downloading) { + mi.setItemVisibility(R.id.cancel_download_item, false); + } + + boolean isInQueue = queueAccess.contains(selectedItem.getId()); + if (!isInQueue || isPlaying) { + mi.setItemVisibility(R.id.remove_from_queue_item, false); + } + if (!(!isInQueue && selectedItem.getMedia() != null)) { + mi.setItemVisibility(R.id.add_to_queue_item, false); + } + if (!showExtendedMenu || selectedItem.getLink() == null) { + mi.setItemVisibility(R.id.share_link_item, false); + } + + if (!BuildConfig.DEBUG + || !(state == FeedItem.State.IN_PROGRESS || state == FeedItem.State.READ)) { + mi.setItemVisibility(R.id.mark_unread_item, false); + } + if (!(state == FeedItem.State.NEW || state == FeedItem.State.IN_PROGRESS)) { + mi.setItemVisibility(R.id.mark_read_item, false); + } + + if (!showExtendedMenu || selectedItem.getLink() == null) { + mi.setItemVisibility(R.id.visit_website_item, false); + } + + if (selectedItem.getPaymentLink() == null || !selectedItem.getFlattrStatus().flattrable()) { + mi.setItemVisibility(R.id.support_item, false); + } + return true; + } + + /** + * The same method as onPrepareMenu(MenuInterface, FeedItem, boolean, QueueAccess), but lets the + * caller also specify a list of menu items that should not be shown. + * + * @param excludeIds Menu item that should be excluded + * @return true if selectedItem is not null. + */ + public static boolean onPrepareMenu(MenuInterface mi, + FeedItem selectedItem, boolean showExtendedMenu, QueueAccess queueAccess, int... excludeIds) { + boolean rc = onPrepareMenu(mi, selectedItem, showExtendedMenu, queueAccess); + if (rc && excludeIds != null) { + for (int id : excludeIds) { + mi.setItemVisibility(id, false); + } + } + + return rc; + } + + public static boolean onMenuItemClicked(Context context, int menuItemId, + FeedItem selectedItem) throws DownloadRequestException { + DownloadRequester requester = DownloadRequester.getInstance(); + switch (menuItemId) { + case R.id.skip_episode_item: + context.sendBroadcast(new Intent( + PlaybackService.ACTION_SKIP_CURRENT_EPISODE)); + break; + case R.id.download_item: + DBTasks.downloadFeedItems(context, selectedItem); + break; + case R.id.play_item: + DBTasks.playMedia(context, selectedItem.getMedia(), true, true, + false); + break; + case R.id.remove_item: + DBWriter.deleteFeedMediaOfItem(context, selectedItem.getMedia().getId()); + break; + case R.id.cancel_download_item: + requester.cancelDownload(context, selectedItem.getMedia()); + break; + case R.id.mark_read_item: + DBWriter.markItemRead(context, selectedItem, true, true); + break; + case R.id.mark_unread_item: + DBWriter.markItemRead(context, selectedItem, false, true); + break; + case R.id.add_to_queue_item: + DBWriter.addQueueItem(context, selectedItem.getId()); + break; + case R.id.remove_from_queue_item: + DBWriter.removeQueueItem(context, selectedItem.getId(), true); + break; + case R.id.stream_item: + DBTasks.playMedia(context, selectedItem.getMedia(), true, true, + true); + break; + case R.id.visit_website_item: + Uri uri = Uri.parse(selectedItem.getLink()); + context.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + break; + case R.id.support_item: + DBTasks.flattrItemIfLoggedIn(context, selectedItem); + break; + case R.id.share_link_item: + ShareUtils.shareFeedItemLink(context, selectedItem); + break; + default: + return false; + } + // Refresh menu state + + return true; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java new file mode 100644 index 000000000..a3adec66d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java @@ -0,0 +1,87 @@ +package de.danoeh.antennapod.util.menuhandler; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.FeedInfoActivity; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.service.download.DownloadService; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DBWriter; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.ShareUtils; + +/** Handles interactions with the FeedItemMenu. */ +public class FeedMenuHandler { + private static final String TAG = "FeedMenuHandler"; + + public static boolean onCreateOptionsMenu(MenuInflater inflater, Menu menu) { + inflater.inflate(R.menu.feedlist, menu); + return true; + } + + public static boolean onPrepareOptionsMenu(Menu menu, Feed selectedFeed) { + if (selectedFeed == null) { + return true; + } + + if (BuildConfig.DEBUG) + Log.d(TAG, "Preparing options menu"); + menu.findItem(R.id.mark_all_read_item).setVisible( + selectedFeed.hasNewItems(true)); + if (selectedFeed.getPaymentLink() != null && selectedFeed.getFlattrStatus().flattrable()) + menu.findItem(R.id.support_item).setVisible(true); + else + menu.findItem(R.id.support_item).setVisible(false); + MenuItem refresh = menu.findItem(R.id.refresh_item); + if (DownloadService.isRunning + && DownloadRequester.getInstance().isDownloadingFile( + selectedFeed)) { + refresh.setVisible(false); + } else { + refresh.setVisible(true); + } + + return true; + } + + /** + * NOTE: This method does not handle clicks on the 'remove feed' - item. + * + * @throws DownloadRequestException + */ + public static boolean onOptionsItemClicked(Context context, MenuItem item, + Feed selectedFeed) throws DownloadRequestException { + switch (item.getItemId()) { + case R.id.refresh_item: + DBTasks.refreshFeed(context, selectedFeed); + break; + case R.id.mark_all_read_item: + DBWriter.markFeedRead(context, selectedFeed.getId()); + break; + case R.id.visit_website_item: + Uri uri = Uri.parse(selectedFeed.getLink()); + context.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + break; + case R.id.support_item: + DBTasks.flattrFeedIfLoggedIn(context, selectedFeed); + break; + case R.id.share_link_item: + ShareUtils.shareFeedlink(context, selectedFeed); + break; + case R.id.share_source_item: + ShareUtils.shareFeedDownloadLink(context, selectedFeed); + break; + default: + return false; + } + return true; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java new file mode 100644 index 000000000..7aa04d24c --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java @@ -0,0 +1,31 @@ +package de.danoeh.antennapod.util.menuhandler; + +import android.support.v4.view.MenuItemCompat; +import android.support.v7.widget.SearchView; +import android.view.Menu; +import android.view.MenuItem; + +import de.danoeh.antennapod.R; + +/** + * Utilities for menu items + */ +public class MenuItemUtils { + + public static MenuItem addSearchItem(Menu menu, SearchView searchView) { + MenuItem item = menu.add(Menu.NONE, R.id.search_item, Menu.NONE, R.string.search_label); + MenuItemCompat.setShowAsAction(item, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); + MenuItemCompat.setActionView(item, searchView); + return item; + } + + /** + * Checks if the navigation drawer of the DrawerActivity is opened. This can be useful for Fragments + * that hide their menu if the navigation drawer is open. + * + * @return True if the drawer is open, false otherwise (also if the parameter is null) + */ + public static boolean isActivityDrawerOpen(NavDrawerActivity activity) { + return activity != null && activity.isDrawerOpen(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java new file mode 100644 index 000000000..9c611a452 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java @@ -0,0 +1,9 @@ +package de.danoeh.antennapod.util.menuhandler; + +/** + * Defines useful methods for activities that have a navigation drawer + */ +public interface NavDrawerActivity { + + public boolean isDrawerOpen(); +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java b/app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java new file mode 100644 index 000000000..bd49b0d18 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.util.playback; + +import android.content.Context; +import android.util.Log; +import android.view.SurfaceHolder; +import com.aocate.media.MediaPlayer; + +public class AudioPlayer extends MediaPlayer implements IPlayer { + private static final String TAG = "AudioPlayer"; + + public AudioPlayer(Context context) { + super(context); + } + + @Override + public void setScreenOnWhilePlaying(boolean screenOn) { + Log.e(TAG, "Setting screen on while playing not supported in Audio Player"); + throw new UnsupportedOperationException("Setting screen on while playing not supported in Audio Player"); + + } + + @Override + public void setDisplay(SurfaceHolder sh) { + if (sh != null) { + Log.e(TAG, "Setting display not supported in Audio Player"); + throw new UnsupportedOperationException("Setting display not supported in Audio Player"); + } + } + + @Override + public void setVideoScalingMode(int mode) { + throw new UnsupportedOperationException("Setting scaling mode is not supported in Audio Player"); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java b/app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java new file mode 100644 index 000000000..3f6e6ae0a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java @@ -0,0 +1,237 @@ +package de.danoeh.antennapod.util.playback; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.util.ChapterUtils; + +import java.io.File; +import java.io.InputStream; +import java.util.List; +import java.util.concurrent.Callable; + +/** Represents a media file that is stored on the local storage device. */ +public class ExternalMedia implements Playable { + + public static final int PLAYABLE_TYPE_EXTERNAL_MEDIA = 2; + public static final String PREF_SOURCE_URL = "ExternalMedia.PrefSourceUrl"; + public static final String PREF_POSITION = "ExternalMedia.PrefPosition"; + public static final String PREF_MEDIA_TYPE = "ExternalMedia.PrefMediaType"; + + private String source; + + private String episodeTitle; + private String feedTitle; + private MediaType mediaType = MediaType.AUDIO; + private List<Chapter> chapters; + private int duration; + private int position; + + public ExternalMedia(String source, MediaType mediaType) { + super(); + this.source = source; + this.mediaType = mediaType; + } + + public ExternalMedia(String source, MediaType mediaType, int position) { + this(source, mediaType); + this.position = position; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(source); + dest.writeString(mediaType.toString()); + dest.writeInt(position); + } + + @Override + public void writeToPreferences(Editor prefEditor) { + prefEditor.putString(PREF_SOURCE_URL, source); + prefEditor.putString(PREF_MEDIA_TYPE, mediaType.toString()); + prefEditor.putInt(PREF_POSITION, position); + } + + @Override + public void loadMetadata() throws PlayableException { + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + try { + mmr.setDataSource(source); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + throw new PlayableException( + "IllegalArgumentException when setting up MediaMetadataReceiver"); + } catch (RuntimeException e) { + // http://code.google.com/p/android/issues/detail?id=39770 + e.printStackTrace(); + throw new PlayableException( + "RuntimeException when setting up MediaMetadataRetriever"); + } + episodeTitle = mmr + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE); + feedTitle = mmr + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM); + try { + duration = Integer.parseInt(mmr + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)); + } catch (NumberFormatException e) { + e.printStackTrace(); + throw new PlayableException("NumberFormatException when reading duration of media file"); + } + ChapterUtils.loadChaptersFromFileUrl(this); + } + + @Override + public void loadChapterMarks() { + + } + + @Override + public String getEpisodeTitle() { + return episodeTitle; + } + + @Override + public Callable<String> loadShownotes() { + return new Callable<String>() { + @Override + public String call() throws Exception { + return ""; + } + }; + } + + @Override + public List<Chapter> getChapters() { + return chapters; + } + + @Override + public String getWebsiteLink() { + return null; + } + + @Override + public String getPaymentLink() { + return null; + } + + @Override + public String getFeedTitle() { + return feedTitle; + } + + @Override + public Object getIdentifier() { + return source; + } + + @Override + public int getDuration() { + return duration; + } + + @Override + public int getPosition() { + return position; + } + + @Override + public MediaType getMediaType() { + return mediaType; + } + + @Override + public String getLocalMediaUrl() { + return source; + } + + @Override + public String getStreamUrl() { + return null; + } + + @Override + public boolean localFileAvailable() { + return true; + } + + @Override + public boolean streamAvailable() { + return false; + } + + @Override + public void saveCurrentPosition(SharedPreferences pref, int newPosition) { + SharedPreferences.Editor editor = pref.edit(); + editor.putInt(PREF_POSITION, newPosition); + position = newPosition; + editor.commit(); + } + + @Override + public void setPosition(int newPosition) { + position = newPosition; + } + + @Override + public void setDuration(int newDuration) { + duration = newDuration; + } + + @Override + public void onPlaybackStart() { + + } + + @Override + public void onPlaybackCompleted() { + + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_EXTERNAL_MEDIA; + } + + @Override + public void setChapters(List<Chapter> chapters) { + this.chapters = chapters; + } + + public static final Parcelable.Creator<ExternalMedia> CREATOR = new Parcelable.Creator<ExternalMedia>() { + public ExternalMedia createFromParcel(Parcel in) { + String source = in.readString(); + MediaType type = MediaType.valueOf(in.readString()); + int position = 0; + if (in.dataAvail() > 0) { + position = in.readInt(); + } + ExternalMedia extMedia = new ExternalMedia(source, type, position); + return extMedia; + } + + public ExternalMedia[] newArray(int size) { + return new ExternalMedia[size]; + } + }; + + @Override + public Uri getImageUri() { + if (localFileAvailable()) { + return new Uri.Builder().scheme(SCHEME_MEDIA).encodedPath(getLocalMediaUrl()).build(); + } else { + return null; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java b/app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java new file mode 100644 index 000000000..2d4551b13 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.util.playback; + +import android.content.Context; +import android.view.SurfaceHolder; + +import java.io.IOException; + +public interface IPlayer { + boolean canSetPitch(); + + boolean canSetSpeed(); + + float getCurrentPitchStepsAdjustment(); + + int getCurrentPosition(); + + float getCurrentSpeedMultiplier(); + + int getDuration(); + + float getMaxSpeedMultiplier(); + + float getMinSpeedMultiplier(); + + boolean isLooping(); + + boolean isPlaying(); + + void pause(); + + void prepare() throws IllegalStateException, IOException; + + void prepareAsync(); + + void release(); + + void reset(); + + void seekTo(int msec); + + void setAudioStreamType(int streamtype); + + void setScreenOnWhilePlaying(boolean screenOn); + + void setDataSource(String path) throws IllegalStateException, IOException, + IllegalArgumentException, SecurityException; + + void setDisplay(SurfaceHolder sh); + + void setEnableSpeedAdjustment(boolean enableSpeedAdjustment); + + void setLooping(boolean looping); + + void setPitchStepsAdjustment(float pitchSteps); + + void setPlaybackPitch(float f); + + void setPlaybackSpeed(float f); + + void setVolume(float left, float right); + + void start(); + + void stop(); + + public void setVideoScalingMode(int mode); + + public void setWakeMode(Context context, int mode); +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java b/app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java new file mode 100644 index 000000000..23ead581f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java @@ -0,0 +1,23 @@ +package de.danoeh.antennapod.util.playback; + +import android.content.Context; +import android.media.MediaPlayer; +import de.danoeh.antennapod.R; + +/** Utility class for MediaPlayer errors. */ +public class MediaPlayerError { + + /** Get a human-readable string for a specific error code. */ + public static String getErrorString(Context context, int code) { + int resId; + switch(code) { + case MediaPlayer.MEDIA_ERROR_SERVER_DIED: + resId = R.string.playback_error_server_died; + break; + default: + resId = R.string.playback_error_unknown; + break; + } + return context.getString(resId); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java b/app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java new file mode 100644 index 000000000..004ae56bb --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java @@ -0,0 +1,207 @@ +package de.danoeh.antennapod.util.playback; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Parcelable; +import android.util.Log; + +import java.util.List; + +import de.danoeh.antennapod.asynctask.PicassoImageResource; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.util.ShownotesProvider; + +/** + * Interface for objects that can be played by the PlaybackService. + */ +public interface Playable extends Parcelable, + ShownotesProvider, PicassoImageResource { + + /** + * Save information about the playable in a preference so that it can be + * restored later via PlayableUtils.createInstanceFromPreferences. + * Implementations must NOT call commit() after they have written the values + * to the preferences file. + */ + public void writeToPreferences(SharedPreferences.Editor prefEditor); + + /** + * This method is called from a separate thread by the PlaybackService. + * Playable objects should load their metadata in this method. This method + * should execute as quickly as possible and NOT load chapter marks if no + * local file is available. + */ + public void loadMetadata() throws PlayableException; + + /** + * This method is called from a separate thread by the PlaybackService. + * Playable objects should load their chapter marks in this method if no + * local file was available when loadMetadata() was called. + */ + public void loadChapterMarks(); + + /** + * Returns the title of the episode that this playable represents + */ + public String getEpisodeTitle(); + + /** + * Returns a list of chapter marks or null if this Playable has no chapters. + */ + public List<Chapter> getChapters(); + + /** + * Returns a link to a website that is meant to be shown in a browser + */ + public String getWebsiteLink(); + + public String getPaymentLink(); + + /** + * Returns the title of the feed this Playable belongs to. + */ + public String getFeedTitle(); + + /** + * Returns a unique identifier, for example a file url or an ID from a + * database. + */ + public Object getIdentifier(); + + /** + * Return duration of object or 0 if duration is unknown. + */ + public int getDuration(); + + /** + * Return position of object or 0 if position is unknown. + */ + public int getPosition(); + + /** + * Returns the type of media. This method should return the correct value + * BEFORE loadMetadata() is called. + */ + public MediaType getMediaType(); + + /** + * Returns an url to a local file that can be played or null if this file + * does not exist. + */ + public String getLocalMediaUrl(); + + /** + * Returns an url to a file that can be streamed by the player or null if + * this url is not known. + */ + public String getStreamUrl(); + + /** + * Returns true if a local file that can be played is available. getFileUrl + * MUST return a non-null string if this method returns true. + */ + public boolean localFileAvailable(); + + /** + * Returns true if a streamable file is available. getStreamUrl MUST return + * a non-null string if this method returns true. + */ + public boolean streamAvailable(); + + /** + * Saves the current position of this object. Implementations can use the + * provided SharedPreference to save this information and retrieve it later + * via PlayableUtils.createInstanceFromPreferences. + */ + public void saveCurrentPosition(SharedPreferences pref, int newPosition); + + public void setPosition(int newPosition); + + public void setDuration(int newDuration); + + /** + * Is called by the PlaybackService when playback starts. + */ + public void onPlaybackStart(); + + /** + * Is called by the PlaybackService when playback is completed. + */ + public void onPlaybackCompleted(); + + /** + * Returns an integer that must be unique among all Playable classes. The + * return value is later used by PlayableUtils to determine the type of the + * Playable object that is restored. + */ + public int getPlayableType(); + + public void setChapters(List<Chapter> chapters); + + /** + * Provides utility methods for Playable objects. + */ + public static class PlayableUtils { + private static final String TAG = "PlayableUtils"; + + /** + * Restores a playable object from a sharedPreferences file. This method might load data from the database, + * depending on the type of playable that was restored. + * + * @param type An integer that represents the type of the Playable object + * that is restored. + * @param pref The SharedPreferences file from which the Playable object + * is restored + * @return The restored Playable object + */ + public static Playable createInstanceFromPreferences(Context context, int type, + SharedPreferences pref) { + // ADD new Playable types here: + switch (type) { + case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA: + long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1); + if (mediaId != -1) { + return DBReader.getFeedMedia(context, mediaId); + } + break; + case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA: + String source = pref.getString(ExternalMedia.PREF_SOURCE_URL, + null); + String mediaType = pref.getString( + ExternalMedia.PREF_MEDIA_TYPE, null); + if (source != null && mediaType != null) { + int position = pref.getInt(ExternalMedia.PREF_POSITION, 0); + return new ExternalMedia(source, + MediaType.valueOf(mediaType), position); + } + break; + } + Log.e(TAG, "Could not restore Playable object from preferences"); + return null; + } + } + + public static class PlayableException extends Exception { + private static final long serialVersionUID = 1L; + + public PlayableException() { + super(); + } + + public PlayableException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public PlayableException(String detailMessage) { + super(detailMessage); + } + + public PlayableException(Throwable throwable) { + super(throwable); + } + + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java b/app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java new file mode 100644 index 000000000..64dbf4868 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java @@ -0,0 +1,784 @@ +package de.danoeh.antennapod.util.playback; + +import android.app.Activity; +import android.content.*; +import android.content.res.TypedArray; +import android.media.MediaPlayer; +import android.os.AsyncTask; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; +import android.util.Pair; +import android.view.SurfaceHolder; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ImageButton; +import android.widget.SeekBar; +import android.widget.TextView; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.FeedMedia; +import de.danoeh.antennapod.feed.MediaType; +import de.danoeh.antennapod.preferences.PlaybackPreferences; +import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.playback.PlaybackService; +import de.danoeh.antennapod.service.playback.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.service.playback.PlayerStatus; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.util.Converter; +import de.danoeh.antennapod.util.playback.Playable.PlayableUtils; + +import java.util.concurrent.*; + +/** + * Communicates with the playback service. GUI classes should use this class to + * control playback instead of communicating with the PlaybackService directly. + */ +public abstract class PlaybackController { + private static final String TAG = "PlaybackController"; + + public static final int INVALID_TIME = -1; + + private final Activity activity; + + private PlaybackService playbackService; + private Playable media; + private PlayerStatus status; + + private ScheduledThreadPoolExecutor schedExecutor; + private static final int SCHED_EX_POOLSIZE = 1; + + protected MediaPositionObserver positionObserver; + protected ScheduledFuture positionObserverFuture; + + private boolean mediaInfoLoaded = false; + private boolean released = false; + + /** + * True if controller should reinit playback service if 'pause' button is + * pressed. + */ + private boolean reinitOnPause; + + public PlaybackController(Activity activity, boolean reinitOnPause) { + Validate.notNull(activity); + + this.activity = activity; + this.reinitOnPause = reinitOnPause; + schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOLSIZE, + new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setPriority(Thread.MIN_PRIORITY); + return t; + } + }, new RejectedExecutionHandler() { + + @Override + public void rejectedExecution(Runnable r, + ThreadPoolExecutor executor) { + Log.w(TAG, + "Rejected execution of runnable in schedExecutor"); + } + } + ); + } + + /** + * Creates a new connection to the playbackService. Should be called in the + * activity's onResume() method. + */ + public void init() { + activity.registerReceiver(statusUpdate, new IntentFilter( + PlaybackService.ACTION_PLAYER_STATUS_CHANGED)); + + activity.registerReceiver(notificationReceiver, new IntentFilter( + PlaybackService.ACTION_PLAYER_NOTIFICATION)); + + activity.registerReceiver(shutdownReceiver, new IntentFilter( + PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + + if (!released) { + bindToService(); + } else { + throw new IllegalStateException( + "Can't call init() after release() has been called"); + } + } + + /** + * Should be called if the PlaybackController is no longer needed, for + * example in the activity's onStop() method. + */ + public void release() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Releasing PlaybackController"); + + try { + activity.unregisterReceiver(statusUpdate); + } catch (IllegalArgumentException e) { + // ignore + } + + try { + activity.unregisterReceiver(notificationReceiver); + } catch (IllegalArgumentException e) { + // ignore + } + + try { + activity.unbindService(mConnection); + } catch (IllegalArgumentException e) { + // ignore + } + + try { + activity.unregisterReceiver(shutdownReceiver); + } catch (IllegalArgumentException e) { + // ignore + } + cancelPositionObserver(); + schedExecutor.shutdownNow(); + media = null; + released = true; + + } + + /** + * Should be called in the activity's onPause() method. + */ + public void pause() { + mediaInfoLoaded = false; + } + + /** + * Tries to establish a connection to the PlaybackService. If it isn't + * running, the PlaybackService will be started with the last played media + * as the arguments of the launch intent. + */ + private void bindToService() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Trying to connect to service"); + AsyncTask<Void, Void, Intent> intentLoader = new AsyncTask<Void, Void, Intent>() { + @Override + protected Intent doInBackground(Void... voids) { + return getPlayLastPlayedMediaIntent(); + } + + @Override + protected void onPostExecute(Intent serviceIntent) { + boolean bound = false; + if (!PlaybackService.started) { + if (serviceIntent != null) { + if (BuildConfig.DEBUG) Log.d(TAG, "Calling start service"); + activity.startService(serviceIntent); + bound = activity.bindService(serviceIntent, mConnection, 0); + } else { + status = PlayerStatus.STOPPED; + setupGUI(); + handleStatus(); + } + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, + "PlaybackService is running, trying to connect without start command."); + bound = activity.bindService(new Intent(activity, + PlaybackService.class), mConnection, 0); + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Result for service binding: " + bound); + } + }; + intentLoader.execute(); + } + + /** + * Returns an intent that starts the PlaybackService and plays the last + * played media or null if no last played media could be found. + */ + private Intent getPlayLastPlayedMediaIntent() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Trying to restore last played media"); + SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(activity.getApplicationContext()); + long currentlyPlayingMedia = PlaybackPreferences + .getCurrentlyPlayingMedia(); + if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) { + Playable media = PlayableUtils.createInstanceFromPreferences(activity, + (int) currentlyPlayingMedia, prefs); + if (media != null) { + Intent serviceIntent = new Intent(activity, + PlaybackService.class); + serviceIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); + serviceIntent.putExtra( + PlaybackService.EXTRA_START_WHEN_PREPARED, false); + serviceIntent.putExtra( + PlaybackService.EXTRA_PREPARE_IMMEDIATELY, false); + boolean fileExists = media.localFileAvailable(); + boolean lastIsStream = PlaybackPreferences + .getCurrentEpisodeIsStream(); + if (!fileExists && !lastIsStream && media instanceof FeedMedia) { + DBTasks.notifyMissingFeedMediaFile( + activity, (FeedMedia) media); + } + serviceIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, + lastIsStream || !fileExists); + return serviceIntent; + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "No last played media found"); + return null; + } + + public abstract void setupGUI(); + + private void setupPositionObserver() { + if ((positionObserverFuture != null && positionObserverFuture + .isCancelled()) + || (positionObserverFuture != null && positionObserverFuture + .isDone()) || positionObserverFuture == null) { + + if (BuildConfig.DEBUG) + Log.d(TAG, "Setting up position observer"); + positionObserver = new MediaPositionObserver(); + positionObserverFuture = schedExecutor.scheduleWithFixedDelay( + positionObserver, MediaPositionObserver.WAITING_INTERVALL, + MediaPositionObserver.WAITING_INTERVALL, + TimeUnit.MILLISECONDS); + } + } + + private void cancelPositionObserver() { + if (positionObserverFuture != null) { + boolean result = positionObserverFuture.cancel(true); + if (BuildConfig.DEBUG) + Log.d(TAG, "PositionObserver cancelled. Result: " + result); + } + } + + public abstract void onPositionObserverUpdate(); + + private ServiceConnection mConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + playbackService = ((PlaybackService.LocalBinder) service) + .getService(); + if (!released) { + queryService(); + if (BuildConfig.DEBUG) + Log.d(TAG, "Connection to Service established"); + } else { + Log.i(TAG, "Connection to playback service has been established, but controller has already been released"); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + playbackService = null; + if (BuildConfig.DEBUG) + Log.d(TAG, "Disconnected from Service"); + + } + }; + + protected BroadcastReceiver statusUpdate = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received statusUpdate Intent."); + if (isConnectedToPlaybackService()) { + PlaybackServiceMediaPlayer.PSMPInfo info = playbackService.getPSMPInfo(); + status = info.playerStatus; + media = info.playable; + handleStatus(); + } else { + Log.w(TAG, + "Couldn't receive status update: playbackService was null"); + bindToService(); + } + } + }; + + protected BroadcastReceiver notificationReceiver = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + if (isConnectedToPlaybackService()) { + int type = intent.getIntExtra( + PlaybackService.EXTRA_NOTIFICATION_TYPE, -1); + int code = intent.getIntExtra( + PlaybackService.EXTRA_NOTIFICATION_CODE, -1); + if (code != -1 && type != -1) { + switch (type) { + case PlaybackService.NOTIFICATION_TYPE_ERROR: + handleError(code); + break; + case PlaybackService.NOTIFICATION_TYPE_BUFFER_UPDATE: + float progress = ((float) code) / 100; + onBufferUpdate(progress); + break; + case PlaybackService.NOTIFICATION_TYPE_RELOAD: + cancelPositionObserver(); + mediaInfoLoaded = false; + queryService(); + onReloadNotification(intent.getIntExtra( + PlaybackService.EXTRA_NOTIFICATION_CODE, -1)); + break; + case PlaybackService.NOTIFICATION_TYPE_SLEEPTIMER_UPDATE: + onSleepTimerUpdate(); + break; + case PlaybackService.NOTIFICATION_TYPE_BUFFER_START: + onBufferStart(); + break; + case PlaybackService.NOTIFICATION_TYPE_BUFFER_END: + onBufferEnd(); + break; + case PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END: + onPlaybackEnd(); + break; + case PlaybackService.NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE: + onPlaybackSpeedChange(); + break; + } + + } else { + if (BuildConfig.DEBUG) + Log.d(TAG, "Bad arguments. Won't handle intent"); + } + } else { + bindToService(); + } + } + + }; + + private BroadcastReceiver shutdownReceiver = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + if (isConnectedToPlaybackService()) { + if (StringUtils.equals(intent.getAction(), + PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)) { + release(); + onShutdownNotification(); + } + } + } + }; + + public abstract void onPlaybackSpeedChange(); + + public abstract void onShutdownNotification(); + + /** + * Called when the currently displayed information should be refreshed. + */ + public abstract void onReloadNotification(int code); + + public abstract void onBufferStart(); + + public abstract void onBufferEnd(); + + public abstract void onBufferUpdate(float progress); + + public abstract void onSleepTimerUpdate(); + + public abstract void handleError(int code); + + public abstract void onPlaybackEnd(); + + /** + * Is called whenever the PlaybackService changes it's status. This method + * should be used to update the GUI or start/cancel background threads. + */ + private void handleStatus() { + final int playResource; + final int pauseResource; + final CharSequence playText = activity.getString(R.string.play_label); + final CharSequence pauseText = activity.getString(R.string.pause_label); + + if (PlaybackService.getCurrentMediaType() == MediaType.AUDIO) { + TypedArray res = activity.obtainStyledAttributes(new int[]{ + R.attr.av_play, R.attr.av_pause}); + playResource = res.getResourceId(0, R.drawable.av_play); + pauseResource = res.getResourceId(1, R.drawable.av_pause); + res.recycle(); + } else { + playResource = R.drawable.ic_action_play_over_video; + pauseResource = R.drawable.ic_action_pause_over_video; + } + + switch (status) { + + case ERROR: + postStatusMsg(R.string.player_error_msg); + handleError(MediaPlayer.MEDIA_ERROR_UNKNOWN); + break; + case PAUSED: + clearStatusMsg(); + checkMediaInfoLoaded(); + cancelPositionObserver(); + updatePlayButtonAppearance(playResource, playText); + if (PlaybackService.getCurrentMediaType() == MediaType.VIDEO) { + setScreenOn(false); + } + break; + case PLAYING: + clearStatusMsg(); + checkMediaInfoLoaded(); + if (PlaybackService.getCurrentMediaType() == MediaType.VIDEO) { + onAwaitingVideoSurface(); + setScreenOn(true); + } + setupPositionObserver(); + updatePlayButtonAppearance(pauseResource, pauseText); + break; + case PREPARING: + postStatusMsg(R.string.player_preparing_msg); + checkMediaInfoLoaded(); + if (playbackService != null) { + if (playbackService.isStartWhenPrepared()) { + updatePlayButtonAppearance(pauseResource, pauseText); + } else { + updatePlayButtonAppearance(playResource, playText); + } + } + break; + case STOPPED: + postStatusMsg(R.string.player_stopped_msg); + break; + case PREPARED: + checkMediaInfoLoaded(); + postStatusMsg(R.string.player_ready_msg); + updatePlayButtonAppearance(playResource, playText); + break; + case SEEKING: + postStatusMsg(R.string.player_seeking_msg); + break; + case INITIALIZED: + checkMediaInfoLoaded(); + clearStatusMsg(); + updatePlayButtonAppearance(playResource, playText); + break; + } + } + + private void checkMediaInfoLoaded() { + mediaInfoLoaded = (mediaInfoLoaded || loadMediaInfo()); + } + + private void updatePlayButtonAppearance(int resource, CharSequence contentDescription) { + ImageButton butPlay = getPlayButton(); + butPlay.setImageResource(resource); + butPlay.setContentDescription(contentDescription); + } + + public abstract ImageButton getPlayButton(); + + public abstract void postStatusMsg(int msg); + + public abstract void clearStatusMsg(); + + public abstract boolean loadMediaInfo(); + + public abstract void onAwaitingVideoSurface(); + + /** + * Called when connection to playback service has been established or + * information has to be refreshed + */ + void queryService() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Querying service info"); + if (playbackService != null) { + status = playbackService.getStatus(); + media = playbackService.getPlayable(); + /* + if (media == null) { + Log.w(TAG, + "PlaybackService has no media object. Trying to restore last played media."); + Intent serviceIntent = getPlayLastPlayedMediaIntent(); + if (serviceIntent != null) { + activity.startService(serviceIntent); + } + } + */ + onServiceQueried(); + + setupGUI(); + handleStatus(); + // make sure that new media is loaded if it's available + mediaInfoLoaded = false; + + } else { + Log.e(TAG, + "queryService() was called without an existing connection to playbackservice"); + } + } + + public abstract void onServiceQueried(); + + /** + * Should be used by classes which implement the OnSeekBarChanged interface. + */ + public float onSeekBarProgressChanged(SeekBar seekBar, int progress, + boolean fromUser, TextView txtvPosition) { + if (fromUser && playbackService != null && media != null) { + float prog = progress / ((float) seekBar.getMax()); + int duration = media.getDuration(); + txtvPosition.setText(Converter + .getDurationStringLong((int) (prog * duration))); + return prog; + } + return 0; + + } + + /** + * Should be used by classes which implement the OnSeekBarChanged interface. + */ + public void onSeekBarStartTrackingTouch(SeekBar seekBar) { + // interrupt position Observer, restart later + cancelPositionObserver(); + } + + /** + * Should be used by classes which implement the OnSeekBarChanged interface. + */ + public void onSeekBarStopTrackingTouch(SeekBar seekBar, float prog) { + if (playbackService != null) { + playbackService.seekTo((int) (prog * media.getDuration())); + setupPositionObserver(); + } + } + + /** + * Should be implemented by classes that show a video. The default implementation + * does nothing + * + * @param enable True if the screen should be kept on, false otherwise + */ + protected void setScreenOn(boolean enable) { + + } + + public OnClickListener newOnPlayButtonClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + if (playbackService != null) { + switch (status) { + case PLAYING: + playbackService.pause(true, reinitOnPause); + break; + case PAUSED: + case PREPARED: + playbackService.resume(); + break; + case PREPARING: + playbackService.setStartWhenPrepared(!playbackService + .isStartWhenPrepared()); + if (reinitOnPause + && playbackService.isStartWhenPrepared() == false) { + playbackService.reinit(); + } + break; + case INITIALIZED: + playbackService.setStartWhenPrepared(true); + playbackService.prepare(); + break; + } + } else { + Log.w(TAG, + "Play/Pause button was pressed, but playbackservice was null!"); + } + } + + }; + } + + public OnClickListener newOnRevButtonClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + if (status == PlayerStatus.PLAYING) { + playbackService.seekDelta(-UserPreferences.getSeekDeltaMs()); + } + } + }; + } + + public OnClickListener newOnFFButtonClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + if (status == PlayerStatus.PLAYING) { + playbackService.seekDelta(UserPreferences.getSeekDeltaMs()); + } + } + }; + } + + public boolean serviceAvailable() { + return playbackService != null; + } + + public int getPosition() { + if (playbackService != null) { + return playbackService.getCurrentPosition(); + } else { + return PlaybackService.INVALID_TIME; + } + } + + public int getDuration() { + if (playbackService != null) { + return playbackService.getDuration(); + } else { + return PlaybackService.INVALID_TIME; + } + } + + public Playable getMedia() { + return media; + } + + public boolean sleepTimerActive() { + return playbackService != null && playbackService.sleepTimerActive(); + } + + public boolean sleepTimerNotActive() { + return playbackService != null && !playbackService.sleepTimerActive(); + } + + public void disableSleepTimer() { + if (playbackService != null) { + playbackService.disableSleepTimer(); + } + } + + public long getSleepTimerTimeLeft() { + if (playbackService != null) { + return playbackService.getSleepTimerTimeLeft(); + } else { + return INVALID_TIME; + } + } + + public void setSleepTimer(long time) { + if (playbackService != null) { + playbackService.setSleepTimer(time); + } + } + + public void seekToChapter(Chapter chapter) { + if (playbackService != null) { + playbackService.seekToChapter(chapter); + } + } + + public void seekTo(int time) { + if (playbackService != null) { + playbackService.seekTo(time); + } + } + + public void setVideoSurface(SurfaceHolder holder) { + if (playbackService != null) { + playbackService.setVideoSurface(holder); + } + } + + public PlayerStatus getStatus() { + return status; + } + + public boolean canSetPlaybackSpeed() { + return playbackService != null && playbackService.canSetSpeed(); + } + + public void setPlaybackSpeed(float speed) { + if (playbackService != null) { + playbackService.setSpeed(speed); + } + } + + public float getCurrentPlaybackSpeedMultiplier() { + if (canSetPlaybackSpeed()) { + return playbackService.getCurrentPlaybackSpeed(); + } else { + return -1; + } + } + + public boolean isPlayingVideo() { + if (playbackService != null) { + return PlaybackService.getCurrentMediaType() == MediaType.VIDEO; + } + return false; + } + + public Pair<Integer, Integer> getVideoSize() { + if (playbackService != null) { + return playbackService.getVideoSize(); + } else { + return null; + } + } + + + /** + * Returns true if PlaybackController can communicate with the playback + * service. + */ + public boolean isConnectedToPlaybackService() { + return playbackService != null; + } + + public void notifyVideoSurfaceAbandoned() { + if (playbackService != null) { + playbackService.notifyVideoSurfaceAbandoned(); + } + } + + /** + * Move service into INITIALIZED state if it's paused to save bandwidth + */ + public void reinitServiceIfPaused() { + if (playbackService != null + && playbackService.isStreaming() + && (playbackService.getStatus() == PlayerStatus.PAUSED || (playbackService + .getStatus() == PlayerStatus.PREPARING && playbackService + .isStartWhenPrepared() == false))) { + playbackService.reinit(); + } + } + + /** + * Refreshes the current position of the media file that is playing. + */ + public class MediaPositionObserver implements Runnable { + + public static final int WAITING_INTERVALL = 1000; + + @Override + public void run() { + if (playbackService != null && playbackService.getStatus() == PlayerStatus.PLAYING) { + activity.runOnUiThread(new Runnable() { + + @Override + public void run() { + onPositionObserverUpdate(); + } + }); + } + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java b/app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java new file mode 100644 index 000000000..ceed68183 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java @@ -0,0 +1,161 @@ +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.apache.commons.lang3.Validate; +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 = "@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; } a.timecode { color: #669900; } 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 class=\"timecode\" 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) { + final Playable playable = (shownotesProvider instanceof Playable) ? (Playable) shownotesProvider : null; + + // 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); + int time = (h != null) ? Converter.durationStringLongToMs(group) : + Converter.durationStringShortToMs(group); + + String rep; + if (playable == null || playable.getDuration() > time) { + rep = String.format(TIMECODE_LINK, time, group); + } else { + rep = group; + } + matcherLong.appendReplacement(buffer, rep); + } + matcherLong.appendTail(buffer); + + 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 int getTimecodeLinkTime(String link) { + if (isTimecodeLink(link)) { + Matcher m = TIMECODE_LINK_REGEX.matcher(link); + + try { + if (m.find()) { + return Integer.valueOf(m.group(1)); + } + } catch (NumberFormatException e) { + e.printStackTrace(); + } + } + return -1; + } + + + public void setShownotesProvider(ShownotesProvider shownotesProvider) { + Validate.notNull(shownotesProvider); + this.shownotesProvider = shownotesProvider; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java b/app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java new file mode 100644 index 000000000..ea9c692ab --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java @@ -0,0 +1,67 @@ +package de.danoeh.antennapod.util.playback; + +import android.media.MediaPlayer; +import android.util.Log; + +public class VideoPlayer extends MediaPlayer implements IPlayer { + private static final String TAG = "VideoPlayer"; + + @Override + public boolean canSetPitch() { + return false; + } + + @Override + public boolean canSetSpeed() { + return false; + } + + @Override + public float getCurrentPitchStepsAdjustment() { + return 1; + } + + @Override + public float getCurrentSpeedMultiplier() { + return 1; + } + + @Override + public float getMaxSpeedMultiplier() { + return 1; + } + + @Override + public float getMinSpeedMultiplier() { + return 1; + } + + @Override + public void setEnableSpeedAdjustment(boolean enableSpeedAdjustment) throws UnsupportedOperationException { + Log.e(TAG, "Setting enable speed adjustment unsupported in video player"); + throw new UnsupportedOperationException("Setting enable speed adjustment unsupported in video player"); + } + + @Override + public void setPitchStepsAdjustment(float pitchSteps) { + Log.e(TAG, "Setting pitch steps adjustment unsupported in video player"); + throw new UnsupportedOperationException("Setting pitch steps adjustment unsupported in video player"); + } + + @Override + public void setPlaybackPitch(float f) { + Log.e(TAG, "Setting playback pitch unsupported in video player"); + throw new UnsupportedOperationException("Setting playback pitch unsupported in video player"); + } + + @Override + public void setPlaybackSpeed(float f) { + Log.e(TAG, "Setting playback speed unsupported in video player"); + throw new UnsupportedOperationException("Setting playback speed unsupported in video player"); + } + + @Override + public void setVideoScalingMode(int mode) { + super.setVideoScalingMode(mode); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java b/app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java new file mode 100644 index 000000000..ac38ec876 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java @@ -0,0 +1,78 @@ +package de.danoeh.antennapod.util.syndication; + +import android.net.Uri; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Finds RSS/Atom URLs in a HTML document using the auto-discovery techniques described here: + * <p/> + * http://www.rssboard.org/rss-autodiscovery + * <p/> + * http://blog.whatwg.org/feed-autodiscovery + */ +public class FeedDiscoverer { + + private static final String MIME_RSS = "application/rss+xml"; + private static final String MIME_ATOM = "application/atom+xml"; + + /** + * Discovers links to RSS and Atom feeds in the given File which must be a HTML document. + * + * @return A map which contains the feed URLs as keys and titles as values (the feed URL is also used as a title if + * a title cannot be found). + */ + public Map<String, String> findLinks(File in, String baseUrl) throws IOException { + return findLinks(Jsoup.parse(in, null), baseUrl); + } + + /** + * Discovers links to RSS and Atom feeds in the given File which must be a HTML document. + * + * @return A map which contains the feed URLs as keys and titles as values (the feed URL is also used as a title if + * a title cannot be found). + */ + public Map<String, String> findLinks(String in, String baseUrl) throws IOException { + return findLinks(Jsoup.parse(in), baseUrl); + } + + private Map<String, String> findLinks(Document document, String baseUrl) { + Map<String, String> res = new LinkedHashMap<String, String>(); + Elements links = document.head().getElementsByTag("link"); + for (Element link : links) { + String rel = link.attr("rel"); + String href = link.attr("href"); + if (!StringUtils.isEmpty(href) && + (rel.equals("alternate") || rel.equals("feed"))) { + String type = link.attr("type"); + if (type.equals(MIME_RSS) || type.equals(MIME_ATOM)) { + String title = link.attr("title"); + String processedUrl = processURL(baseUrl, href); + if (processedUrl != null) { + res.put(processedUrl, + (StringUtils.isEmpty(title)) ? href : title); + } + } + } + } + return res; + } + + private String processURL(String baseUrl, String strUrl) { + Uri uri = Uri.parse(strUrl); + if (uri.isRelative()) { + Uri res = Uri.parse(baseUrl).buildUpon().path(strUrl).build(); + return (res != null) ? res.toString() : null; + } else { + return strUrl; + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java new file mode 100644 index 000000000..767034ed2 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java @@ -0,0 +1,81 @@ +package de.danoeh.antennapod.util.vorbiscommentreader; + +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +public class OggInputStream extends InputStream { + private InputStream input; + + /** True if OggInputStream is currently inside an Ogg page. */ + private boolean isInPage; + private long bytesLeft; + + public OggInputStream(InputStream input) { + super(); + isInPage = false; + this.input = input; + } + + @Override + public int read() throws IOException { + if (!isInPage) { + readOggPage(); + } + + if (isInPage && bytesLeft > 0) { + int result = input.read(); + bytesLeft -= 1; + if (bytesLeft == 0) { + isInPage = false; + } + return result; + } + return -1; + } + + private void readOggPage() throws IOException { + // find OggS + int[] buffer = new int[4]; + int c = 0; + boolean isInOggS = false; + while ((c = input.read()) != -1) { + switch (c) { + case 'O': + isInOggS = true; + buffer[0] = c; + break; + case 'g': + if (buffer[1] != c) { + buffer[1] = c; + } else { + buffer[2] = c; + } + break; + case 'S': + buffer[3] = c; + break; + default: + if (isInOggS) { + Arrays.fill(buffer, 0); + isInOggS = false; + } + } + if (buffer[0] == 'O' && buffer[1] == 'g' && buffer[2] == 'g' + && buffer[3] == 'S') { + break; + } + } + // read segments + IOUtils.skipFully(input, 22); + bytesLeft = 0; + int numSegments = input.read(); + for (int i = 0; i < numSegments; i++) { + bytesLeft += input.read(); + } + isInPage = true; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java new file mode 100644 index 000000000..b2f149ddd --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java @@ -0,0 +1,101 @@ +package de.danoeh.antennapod.util.vorbiscommentreader; + +import android.util.Log; +import de.danoeh.antennapod.BuildConfig; +import de.danoeh.antennapod.feed.Chapter; +import de.danoeh.antennapod.feed.VorbisCommentChapter; + +import java.util.ArrayList; +import java.util.List; + +public class VorbisCommentChapterReader extends VorbisCommentReader { + private static final String TAG = "VorbisCommentChapterReader"; + + private static final String CHAPTER_KEY = "chapter\\d\\d\\d.*"; + private static final String CHAPTER_ATTRIBUTE_TITLE = "name"; + private static final String CHAPTER_ATTRIBUTE_LINK = "url"; + + private List<Chapter> chapters; + + public VorbisCommentChapterReader() { + } + + @Override + public void onVorbisCommentFound() { + System.out.println("Vorbis comment found"); + } + + @Override + public void onVorbisCommentHeaderFound(VorbisCommentHeader header) { + chapters = new ArrayList<Chapter>(); + System.out.println(header.toString()); + } + + @Override + public boolean onContentVectorKey(String content) { + return content.matches(CHAPTER_KEY); + } + + @Override + public void onContentVectorValue(String key, String value) + throws VorbisCommentReaderException { + if (BuildConfig.DEBUG) + Log.d(TAG, "Key: " + key + ", value: " + value); + String attribute = VorbisCommentChapter.getAttributeTypeFromKey(key); + int id = VorbisCommentChapter.getIDFromKey(key); + Chapter chapter = getChapterById(id); + if (attribute == null) { + if (getChapterById(id) == null) { + // new chapter + long start = VorbisCommentChapter.getStartTimeFromValue(value); + chapter = new VorbisCommentChapter(id); + chapter.setStart(start); + chapters.add(chapter); + } else { + throw new VorbisCommentReaderException( + "Found chapter with duplicate ID (" + key + ", " + + value + ")"); + } + } else if (attribute.equals(CHAPTER_ATTRIBUTE_TITLE)) { + if (chapter != null) { + chapter.setTitle(value); + } + } else if (attribute.equals(CHAPTER_ATTRIBUTE_LINK)) { + if (chapter != null) { + chapter.setLink(value); + } + } + } + + @Override + public void onNoVorbisCommentFound() { + System.out.println("No vorbis comment found"); + } + + @Override + public void onEndOfComment() { + System.out.println("End of comment"); + for (Chapter c : chapters) { + System.out.println(c.toString()); + } + } + + @Override + public void onError(VorbisCommentReaderException exception) { + exception.printStackTrace(); + } + + private Chapter getChapterById(long id) { + for (Chapter c : chapters) { + if (((VorbisCommentChapter) c).getVorbisCommentId() == id) { + return c; + } + } + return null; + } + + public List<Chapter> getChapters() { + return chapters; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java new file mode 100644 index 000000000..8c47393c9 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.util.vorbiscommentreader; +public class VorbisCommentHeader { + private String vendorString; + private long userCommentLength; + + public VorbisCommentHeader(String vendorString, long userCommentLength) { + super(); + this.vendorString = vendorString; + this.userCommentLength = userCommentLength; + } + + @Override + public String toString() { + return "VorbisCommentHeader [vendorString=" + vendorString + + ", userCommentLength=" + userCommentLength + "]"; + } + + public String getVendorString() { + return vendorString; + } + + public long getUserCommentLength() { + return userCommentLength; + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java new file mode 100644 index 000000000..718a4f30f --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java @@ -0,0 +1,194 @@ +package de.danoeh.antennapod.util.vorbiscommentreader; + +import org.apache.commons.io.EndianUtils; +import org.apache.commons.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.Arrays; + + +public abstract class VorbisCommentReader { + /** Length of first page in an ogg file in bytes. */ + private static final int FIRST_PAGE_LENGTH = 58; + private static final int SECOND_PAGE_MAX_LENGTH = 64 * 1024 * 1024; + private static final int PACKET_TYPE_IDENTIFICATION = 1; + private static final int PACKET_TYPE_COMMENT = 3; + + /** Called when Reader finds identification header. */ + public abstract void onVorbisCommentFound(); + + public abstract void onVorbisCommentHeaderFound(VorbisCommentHeader header); + + /** + * Is called every time the Reader finds a content vector. The handler + * should return true if it wants to handle the content vector. + */ + public abstract boolean onContentVectorKey(String content); + + /** + * Is called if onContentVectorKey returned true for the key. + * + * @throws VorbisCommentReaderException + */ + public abstract void onContentVectorValue(String key, String value) + throws VorbisCommentReaderException; + + public abstract void onNoVorbisCommentFound(); + + public abstract void onEndOfComment(); + + public abstract void onError(VorbisCommentReaderException exception); + + public void readInputStream(InputStream input) + throws VorbisCommentReaderException { + try { + // look for identification header + if (findIdentificationHeader(input)) { + + onVorbisCommentFound(); + input = new OggInputStream(input); + if (findCommentHeader(input)) { + VorbisCommentHeader commentHeader = readCommentHeader(input); + if (commentHeader != null) { + onVorbisCommentHeaderFound(commentHeader); + for (int i = 0; i < commentHeader + .getUserCommentLength(); i++) { + try { + long vectorLength = EndianUtils + .readSwappedUnsignedInteger(input); + String key = readContentVectorKey(input, + vectorLength).toLowerCase(); + boolean readValue = onContentVectorKey(key); + if (readValue) { + String value = readUTF8String( + input, + (int) (vectorLength - key.length() - 1)); + onContentVectorValue(key, value); + } else { + IOUtils.skipFully(input, + vectorLength - key.length() - 1); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + onEndOfComment(); + } + + } else { + onError(new VorbisCommentReaderException( + "No comment header found")); + } + } else { + onNoVorbisCommentFound(); + } + } catch (IOException e) { + onError(new VorbisCommentReaderException(e)); + } + } + + private String readUTF8String(InputStream input, long length) + throws IOException { + byte[] buffer = new byte[(int) length]; + + IOUtils.readFully(input, buffer); + Charset charset = Charset.forName("UTF-8"); + return charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString(); + } + + /** + * Looks for an identification header in the first page of the file. If an + * identification header is found, it will be skipped completely and the + * method will return true, otherwise false. + * + * @throws IOException + */ + private boolean findIdentificationHeader(InputStream input) + throws IOException { + byte[] buffer = new byte[FIRST_PAGE_LENGTH]; + IOUtils.readFully(input, buffer); + int i; + for (i = 6; i < buffer.length; i++) { + if (buffer[i - 5] == 'v' && buffer[i - 4] == 'o' + && buffer[i - 3] == 'r' && buffer[i - 2] == 'b' + && buffer[i - 1] == 'i' && buffer[i] == 's' + && buffer[i - 6] == PACKET_TYPE_IDENTIFICATION) { + return true; + } + } + return false; + } + + private boolean findCommentHeader(InputStream input) throws IOException { + char[] buffer = new char["vorbis".length() + 1]; + for (int bytesRead = 0; bytesRead < SECOND_PAGE_MAX_LENGTH; bytesRead++) { + char c = (char) input.read(); + int dest = -1; + switch (c) { + case PACKET_TYPE_COMMENT: + dest = 0; + break; + case 'v': + dest = 1; + break; + case 'o': + dest = 2; + break; + case 'r': + dest = 3; + break; + case 'b': + dest = 4; + break; + case 'i': + dest = 5; + break; + case 's': + dest = 6; + break; + } + if (dest >= 0) { + buffer[dest] = c; + if (buffer[1] == 'v' && buffer[2] == 'o' && buffer[3] == 'r' + && buffer[4] == 'b' && buffer[5] == 'i' + && buffer[6] == 's' && buffer[0] == PACKET_TYPE_COMMENT) { + return true; + } + } else { + Arrays.fill(buffer, (char) 0); + } + } + return false; + } + + private VorbisCommentHeader readCommentHeader(InputStream input) + throws IOException, VorbisCommentReaderException { + try { + long vendorLength = EndianUtils.readSwappedUnsignedInteger(input); + String vendorName = readUTF8String(input, vendorLength); + long userCommentLength = EndianUtils + .readSwappedUnsignedInteger(input); + return new VorbisCommentHeader(vendorName, userCommentLength); + } catch (UnsupportedEncodingException e) { + throw new VorbisCommentReaderException(e); + } + } + + private String readContentVectorKey(InputStream input, long vectorLength) + throws IOException { + StringBuffer buffer = new StringBuffer(); + for (int i = 0; i < vectorLength; i++) { + char c = (char) input.read(); + if (c == '=') { + return buffer.toString(); + } else { + buffer.append(c); + } + } + return null; // no key found + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java new file mode 100644 index 000000000..574373241 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java @@ -0,0 +1,24 @@ +package de.danoeh.antennapod.util.vorbiscommentreader; +public class VorbisCommentReaderException extends Exception { + + public VorbisCommentReaderException() { + super(); + // TODO Auto-generated constructor stub + } + + public VorbisCommentReaderException(String arg0, Throwable arg1) { + super(arg0, arg1); + // TODO Auto-generated constructor stub + } + + public VorbisCommentReaderException(String arg0) { + super(arg0); + // TODO Auto-generated constructor stub + } + + public VorbisCommentReaderException(Throwable arg0) { + super(arg0); + // TODO Auto-generated constructor stub + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java b/app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java new file mode 100644 index 000000000..f930c912a --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java @@ -0,0 +1,97 @@ +package de.danoeh.antennapod.view; + +/* + * Copyright (C) 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. + */ + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.VideoView; + +public class AspectRatioVideoView extends VideoView { + + + private int mVideoWidth; + private int mVideoHeight; + + public AspectRatioVideoView(Context context) { + this(context, null); + } + + public AspectRatioVideoView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AspectRatioVideoView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + mVideoWidth = 0; + mVideoHeight = 0; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (mVideoWidth <= 0 || mVideoHeight <= 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + + float heightRatio = (float) mVideoHeight / (float) getHeight(); + float widthRatio = (float) mVideoWidth / (float) getWidth(); + + int scaledHeight; + int scaledWidth; + + if (heightRatio > widthRatio) { + scaledHeight = (int) Math.ceil((float) mVideoHeight + / heightRatio); + scaledWidth = (int) Math.ceil((float) mVideoWidth + / heightRatio); + } else { + scaledHeight = (int) Math.ceil((float) mVideoHeight + / widthRatio); + scaledWidth = (int) Math.ceil((float) mVideoWidth + / widthRatio); + } + + setMeasuredDimension(scaledWidth, scaledHeight); + } + + /** + * Source code originally from: + * http://clseto.mysinablog.com/index.php?op=ViewArticle&articleId=2992625 + * + * @param videoWidth + * @param videoHeight + */ + public void setVideoSize(int videoWidth, int videoHeight) { + // Set the new video size + mVideoWidth = videoWidth; + mVideoHeight = videoHeight; + + /** + * If this isn't set the video is stretched across the + * SurfaceHolders display surface (i.e. the SurfaceHolder + * as the same size and the video is drawn to fit this + * display area). We want the size to be the video size + * and allow the aspectratio to handle how the surface is shown + */ + getHolder().setFixedSize(videoWidth, videoHeight); + + requestLayout(); + invalidate(); + } + +} |