diff --git a/res/drawable-hdpi/ic_launcher_folder.png b/res/drawable-hdpi/ic_launcher_folder.png new file mode 100644 index 00000000..8d56e571 Binary files /dev/null and b/res/drawable-hdpi/ic_launcher_folder.png differ diff --git a/res/drawable-hdpi/ic_tab_files_selected.png b/res/drawable-hdpi/ic_tab_files_selected.png new file mode 100644 index 00000000..1032437e Binary files /dev/null and b/res/drawable-hdpi/ic_tab_files_selected.png differ diff --git a/res/drawable-hdpi/ic_tab_files_unselected.png b/res/drawable-hdpi/ic_tab_files_unselected.png new file mode 100644 index 00000000..edb56e4a Binary files /dev/null and b/res/drawable-hdpi/ic_tab_files_unselected.png differ diff --git a/res/drawable-mdpi/ic_launcher_folder.png b/res/drawable-mdpi/ic_launcher_folder.png new file mode 100644 index 00000000..917d3bff Binary files /dev/null and b/res/drawable-mdpi/ic_launcher_folder.png differ diff --git a/res/drawable-mdpi/ic_tab_files_selected.png b/res/drawable-mdpi/ic_tab_files_selected.png new file mode 100644 index 00000000..c92ec219 Binary files /dev/null and b/res/drawable-mdpi/ic_tab_files_selected.png differ diff --git a/res/drawable-mdpi/ic_tab_files_unselected.png b/res/drawable-mdpi/ic_tab_files_unselected.png new file mode 100644 index 00000000..8446b240 Binary files /dev/null and b/res/drawable-mdpi/ic_tab_files_unselected.png differ diff --git a/res/drawable/ic_tab_files.xml b/res/drawable/ic_tab_files.xml new file mode 100644 index 00000000..26ab4370 --- /dev/null +++ b/res/drawable/ic_tab_files.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/res/layout/library_content.xml b/res/layout/library_content.xml index 106a5fad..a2d7b6d6 100644 --- a/res/layout/library_content.xml +++ b/res/layout/library_content.xml @@ -65,12 +65,21 @@ THE SOFTWARE. android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" /> + - + android:layout_gravity="bottom|left"> + + 1 låt lagt til spilleliste %2$s. %1$d låter lagt til spilleliste %2$s. - Spilleliste %s slettet. 1 låt slettet. %d låter slettet. diff --git a/res/values-sk/translatable.xml b/res/values-sk/translatable.xml index 7dc06b78..0853a6ab 100644 --- a/res/values-sk/translatable.xml +++ b/res/values-sk/translatable.xml @@ -65,7 +65,6 @@ THE SOFTWARE. %1$d skladby pridané do zoznamu skladieb %2$s. %1$d skladieb pridaných do zoznamu skladieb %2$s. - Zoznam skladieb %s odstránený. 1 skladba odstránená. %d skladby odstránené. diff --git a/res/values/translatable.xml b/res/values/translatable.xml index f71d1c3b..700caf79 100644 --- a/res/values/translatable.xml +++ b/res/values/translatable.xml @@ -74,17 +74,19 @@ THE SOFTWARE. 1 song added to playlist %2$s. %1$d songs added to playlist %2$s. - Playlist %s deleted. + %s deleted. 1 song deleted. %d songs deleted. + Failed to delete %s. Artists Albums Songs Playlists Genres + Files None Unknown diff --git a/src/org/kreed/vanilla/FileSystemAdapter.java b/src/org/kreed/vanilla/FileSystemAdapter.java new file mode 100644 index 00000000..38c30c2c --- /dev/null +++ b/src/org/kreed/vanilla/FileSystemAdapter.java @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2010, 2011 Christopher Eby + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.kreed.vanilla; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Environment; +import android.os.FileObserver; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import java.io.File; +import java.io.FilenameFilter; +import java.util.Arrays; +import java.util.Comparator; +import java.util.regex.Pattern; + +/** + * A list adapter that provides a view of the filesystem. The active directory + * is set through a {@link Limiter} and rows are displayed using MediaViews. + */ +public class FileSystemAdapter extends BaseAdapter implements LibraryAdapter { + private static final Pattern SPACE_SPLIT = Pattern.compile("\\s+"); + private static final Pattern FILE_SEPARATOR = Pattern.compile(File.separator); + + /** + * The owner LibraryActivity. + */ + final LibraryActivity mActivity; + /** + * The currently active limiter, set by a row expander being clicked. + */ + private Limiter mLimiter; + /** + * The files and folders in the current directory. + */ + private File[] mFiles; + /** + * The folder icon shown for folder rows. + */ + private final Bitmap mFolderIcon; + /** + * The currently active filter, entered by the user from the search box. + */ + String[] mFilter; + /** + * Excludes dot files and files not matching mFilter. + */ + private final FilenameFilter mFileFilter = new FilenameFilter() { + @Override + public boolean accept(File dir, String filename) + { + if (filename.charAt(0) == '.') + return false; + if (mFilter != null) { + filename = filename.toLowerCase(); + for (String term : mFilter) { + if (!filename.contains(term)) + return false; + } + } + return true; + } + }; + /** + * Sorts folders before files first, then sorts alphabetically by name. + */ + private final Comparator mFileComparator = new Comparator() { + @Override + public int compare(File a, File b) + { + boolean aIsFolder = a.isDirectory(); + boolean bIsFolder = b.isDirectory(); + if (bIsFolder == aIsFolder) { + return a.getName().compareToIgnoreCase(b.getName()); + } else if (bIsFolder) { + return 1; + } + return -1; + } + }; + /** + * The Observer instance for the current directory. + */ + private Observer mFileObserver; + + /** + * Create a FileSystemAdapter. + * + * @param activity The LibraryActivity that will contain this adapter. + * Called on to requery this adapter when the contents of the directory + * change. + * @param limiter An initial limiter to set. If none is given, will be set + * to the external storage directory. + */ + public FileSystemAdapter(LibraryActivity activity, Limiter limiter) + { + mActivity = activity; + mLimiter = limiter; + mFolderIcon = BitmapFactory.decodeResource(activity.getResources(), R.drawable.ic_launcher_folder); + if (limiter == null) { + limiter = buildLimiter(Environment.getExternalStorageDirectory()); + } + setLimiter(limiter); + } + + @Override + public Object query() + { + File file = mLimiter == null ? new File("/") : (File)mLimiter.data; + + if (mFileObserver == null) { + mFileObserver = new Observer(file.getPath()); + } + + File[] files = file.listFiles(mFileFilter); + if (files != null) + Arrays.sort(files, mFileComparator); + return files; + } + + @Override + public void commitQuery(Object data) + { + mFiles = (File[])data; + notifyDataSetInvalidated(); + } + + @Override + public void clear() + { + mFiles = null; + notifyDataSetInvalidated(); + } + + @Override + public int getCount() + { + if (mFiles == null) + return 0; + return mFiles.length; + } + + @Override + public Object getItem(int pos) + { + return mFiles[pos]; + } + + @Override + public long getItemId(int pos) + { + return pos; + } + + @Override + public View getView(int pos, View convertView, ViewGroup parent) + { + MediaView view; + if (convertView == null) { + view = new MediaView(mActivity, mFolderIcon, MediaView.sExpander); + } else { + view = (MediaView)convertView; + } + File file = mFiles[pos]; + view.setData(pos, file.getName()); + view.setShowBitmaps(file.isDirectory()); + return view; + } + + @Override + public void setFilter(String filter) + { + mFilter = SPACE_SPLIT.split(filter.toLowerCase()); + } + + @Override + public void setLimiter(Limiter limiter) + { + if (mFileObserver != null) + mFileObserver.stopWatching(); + mFileObserver = null; + mLimiter = limiter; + } + + @Override + public Limiter getLimiter() + { + return mLimiter; + } + + /** + * Builds a limiter from the given folder. Only files contained in the + * given folder will be shown if the limiter is set on this adapter. + * + * @param file A File pointing to a folder. + * @return A limiter describing the given folder. + */ + public static Limiter buildLimiter(File file) + { + String[] fields = FILE_SEPARATOR.split(file.getPath().substring(1)); + return new Limiter(MediaUtils.TYPE_FILE, fields, file); + } + + @Override + public Limiter buildLimiter(long id) + { + return buildLimiter(mFiles[(int)id]); + } + + @Override + public int getMediaType() + { + return MediaUtils.TYPE_FILE; + } + + /** + * FileObserver that reloads the files in this adapter. + */ + private class Observer extends FileObserver { + public Observer(String path) + { + super(path, FileObserver.CREATE | FileObserver.DELETE | FileObserver.MOVED_TO | FileObserver.MOVED_FROM); + startWatching(); + } + + @Override + public void onEvent(int event, String path) + { + mActivity.postRequestRequery(FileSystemAdapter.this); + } + } +} \ No newline at end of file diff --git a/src/org/kreed/vanilla/LibraryActivity.java b/src/org/kreed/vanilla/LibraryActivity.java index 3c751e8c..fdca43cf 100644 --- a/src/org/kreed/vanilla/LibraryActivity.java +++ b/src/org/kreed/vanilla/LibraryActivity.java @@ -26,8 +26,8 @@ import android.app.AlertDialog; import android.content.ContentResolver; import android.content.DialogInterface; import android.content.Intent; -import android.content.res.Resources; import android.content.SharedPreferences; +import android.content.res.Resources; import android.database.ContentObserver; import android.database.Cursor; import android.graphics.Color; @@ -42,6 +42,7 @@ import android.provider.MediaStore; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; +import android.util.Log; import android.view.ContextMenu; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -59,6 +60,8 @@ import android.widget.RadioGroup; import android.widget.TabHost; import android.widget.TextView; import android.widget.Toast; +import java.io.File; +import java.io.IOException; import junit.framework.Assert; /** @@ -101,19 +104,35 @@ public class LibraryActivity private int mLastAction = ACTION_PLAY; private long mLastActedId; - private MediaAdapter[] mAdapters; - private MediaAdapter mArtistAdapter; - private MediaAdapter mAlbumAdapter; - private MediaAdapter mSongAdapter; - private MediaAdapter mPlaylistAdapter; - private MediaAdapter mGenreAdapter; - private MediaAdapter mCurrentAdapter; + /** + * The number of adapters/lists (determines array sizes). + */ + private static final int ADAPTER_COUNT = 6; + /** + * The ListView for each adapter, in the same order as MediaUtils.TYPE_*. + */ + final ListView[] mLists = new ListView[ADAPTER_COUNT]; + /** + * Whether the adapter corresponding to each index has stale data. + */ + final boolean[] mRequeryNeeded = new boolean[ADAPTER_COUNT]; + /** + * Each adapter, in the same order as MediaUtils.TYPE_*. + */ + final LibraryAdapter[] mAdapters = new LibraryAdapter[ADAPTER_COUNT]; + MediaAdapter mArtistAdapter; + MediaAdapter mAlbumAdapter; + MediaAdapter mSongAdapter; + MediaAdapter mPlaylistAdapter; + MediaAdapter mGenreAdapter; + FileSystemAdapter mFilesAdapter; + LibraryAdapter mCurrentAdapter; private final ContentObserver mPlaylistObserver = new ContentObserver(null) { @Override public void onChange(boolean selfChange) { - mUiHandler.sendMessage(mUiHandler.obtainMessage(MSG_REQUEST_REQUERY, mPlaylistAdapter)); + postRequestRequery(mPlaylistAdapter); } }; @@ -168,13 +187,19 @@ public class LibraryActivity mTabHost = (TabHost)findViewById(R.id.tab_host); mTabHost.setup(); - mArtistAdapter = setupView(R.id.artist_list, MediaUtils.TYPE_ARTIST, R.string.artists, R.drawable.ic_tab_artists, true, true, null); - mAlbumAdapter = setupView(R.id.album_list, MediaUtils.TYPE_ALBUM, R.string.albums, R.drawable.ic_tab_albums, true, true, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_albums")); - mSongAdapter = setupView(R.id.song_list, MediaUtils.TYPE_SONG, R.string.songs, R.drawable.ic_tab_songs, false, true, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_songs")); - mPlaylistAdapter = setupView(R.id.playlist_list, MediaUtils.TYPE_PLAYLIST, R.string.playlists, R.drawable.ic_tab_playlists, true, false, null); - mGenreAdapter = setupView(R.id.genre_list, MediaUtils.TYPE_GENRE, R.string.genres, R.drawable.ic_tab_genres, true, false, state == null ? null : (MediaAdapter.Limiter)state.getSerializable("limiter_genres")); - // These should be in the same order as MediaUtils.TYPE_* - mAdapters = new MediaAdapter[] { mArtistAdapter, mAlbumAdapter, mSongAdapter, mPlaylistAdapter, mGenreAdapter }; + mArtistAdapter = new MediaAdapter(this, MediaUtils.TYPE_ARTIST, true, true, null); + mAlbumAdapter = new MediaAdapter(this, MediaUtils.TYPE_ALBUM, true, true, state == null ? null : (Limiter)state.getSerializable("limiter_albums")); + mSongAdapter = new MediaAdapter(this, MediaUtils.TYPE_SONG, false, true, state == null ? null : (Limiter)state.getSerializable("limiter_songs")); + mPlaylistAdapter = new MediaAdapter(this, MediaUtils.TYPE_PLAYLIST, true, false, null); + mGenreAdapter = new MediaAdapter(this, MediaUtils.TYPE_GENRE, true, false, null); + mFilesAdapter = new FileSystemAdapter(this, state == null ? null : (Limiter)state.getSerializable("limiter_files")); + + setupView(0, R.id.artist_list, R.string.artists, R.drawable.ic_tab_artists, mArtistAdapter); + setupView(1, R.id.album_list, R.string.albums, R.drawable.ic_tab_albums, mAlbumAdapter); + setupView(2, R.id.song_list, R.string.songs, R.drawable.ic_tab_songs, mSongAdapter); + setupView(3, R.id.playlist_list, R.string.playlists, R.drawable.ic_tab_playlists, mPlaylistAdapter); + setupView(4, R.id.genre_list, R.string.genres, R.drawable.ic_tab_genres, mGenreAdapter); + setupView(5, R.id.file_list, R.string.files, R.drawable.ic_tab_files, mFilesAdapter); getContentResolver().registerContentObserver(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, true, mPlaylistObserver); @@ -219,7 +244,7 @@ public class LibraryActivity out.putString("filter", mTextFilter.getText().toString()); out.putSerializable("limiter_albums", mAlbumAdapter.getLimiter()); out.putSerializable("limiter_songs", mSongAdapter.getLimiter()); - out.putSerializable("limiter_genres", mGenreAdapter.getLimiter()); + out.putSerializable("limiter_files", mFilesAdapter.getLimiter()); } @Override @@ -282,7 +307,7 @@ public class LibraryActivity * Adds songs matching the data from the given intent to the song timelime. * * @param intent An intent created with - * {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}. + * {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}. * @param action One of LibraryActivity.ACTION_* */ private void pickSongs(Intent intent, int action) @@ -295,7 +320,7 @@ public class LibraryActivity boolean all = false; int mode = action; if (action == ACTION_PLAY_ALL || action == ACTION_ENQUEUE_ALL) { - MediaAdapter adapter = mCurrentAdapter; + LibraryAdapter adapter = mCurrentAdapter; boolean notPlayAllAdapter = (adapter != mSongAdapter && adapter != mAlbumAdapter && adapter != mArtistAdapter) || id == MediaView.HEADER_ID; if (mode == ACTION_ENQUEUE_ALL && notPlayAllAdapter) { @@ -324,13 +349,38 @@ public class LibraryActivity * from the view and switching to the appropriate tab. * * @param intent An intent created with - * {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}. + * {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}. */ private void expand(Intent intent) { int type = intent.getIntExtra("type", 1); long id = intent.getLongExtra("id", -1); - mTabHost.setCurrentTab(setLimiter(mAdapters[type - 1].getLimiter(id))); + int tab = setLimiter(mAdapters[type - 1].buildLimiter(id)); + if (tab == -1 || mTabHost.getCurrentTab() == tab) { + updateLimiterViews(); + } else { + mTabHost.setCurrentTab(tab); + } + } + + /** + * Clear a limiter. + * + * @param type Which type of limiter to clear. + */ + private void clearLimiter(int type) + { + if (type == MediaUtils.TYPE_FILE) { + mFilesAdapter.setLimiter(null); + requestRequery(mFilesAdapter); + } else { + mAlbumAdapter.setLimiter(null); + mSongAdapter.setLimiter(null); + loadSortOrder(mSongAdapter); + loadSortOrder(mAlbumAdapter); + requestRequery(mSongAdapter); + requestRequery(mAlbumAdapter); + } } /** @@ -338,34 +388,32 @@ public class LibraryActivity * * @return The tab to "expand" to */ - private int setLimiter(MediaAdapter.Limiter limiter) + private int setLimiter(Limiter limiter) { int tab; - if (limiter == null) { + switch (limiter.type) { + case MediaUtils.TYPE_ALBUM: + mSongAdapter.setLimiter(limiter); + loadSortOrder(mSongAdapter); + requestRequery(mSongAdapter); + return 2; + case MediaUtils.TYPE_ARTIST: + mAlbumAdapter.setLimiter(limiter); + mSongAdapter.setLimiter(limiter); + tab = 1; + break; + case MediaUtils.TYPE_GENRE: + mSongAdapter.setLimiter(limiter); mAlbumAdapter.setLimiter(null); - mSongAdapter.setLimiter(null); - tab = -1; - } else { - switch (limiter.type) { - case MediaUtils.TYPE_ALBUM: - mSongAdapter.setLimiter(limiter); - loadSortOrder(mSongAdapter); - requestRequery(mSongAdapter); - return 2; - case MediaUtils.TYPE_ARTIST: - mAlbumAdapter.setLimiter(limiter); - mSongAdapter.setLimiter(limiter); - tab = 1; - break; - case MediaUtils.TYPE_GENRE: - mSongAdapter.setLimiter(limiter); - mAlbumAdapter.setLimiter(null); - tab = 2; - break; - default: - throw new IllegalArgumentException("Unsupported limiter type: " + limiter.type); - } + tab = 2; + break; + case MediaUtils.TYPE_FILE: + mFilesAdapter.setLimiter(limiter); + requestRequery(mFilesAdapter); + return 5; + default: + throw new IllegalArgumentException("Unsupported limiter type: " + limiter.type); } loadSortOrder(mSongAdapter); @@ -392,7 +440,7 @@ public class LibraryActivity public void onItemClick(AdapterView list, View view, int pos, long id) { MediaView mediaView = (MediaView)view; - MediaAdapter adapter = (MediaAdapter)list.getAdapter(); + LibraryAdapter adapter = (LibraryAdapter)list.getAdapter(); if (mediaView.isRightBitmapPressed()) { if (adapter == mPlaylistAdapter) editPlaylist(mediaView.getMediaId(), mediaView.getTitle()); @@ -419,7 +467,7 @@ public class LibraryActivity public void onTextChanged(CharSequence text, int start, int before, int count) { String filter = text.toString(); - for (MediaAdapter adapter : mAdapters) { + for (LibraryAdapter adapter : mAdapters) { adapter.setFilter(filter); requestRequery(adapter); } @@ -432,11 +480,11 @@ public class LibraryActivity mLimiterViews.removeAllViews(); - MediaAdapter adapter = mCurrentAdapter; + LibraryAdapter adapter = mCurrentAdapter; if (adapter == null) return; - MediaAdapter.Limiter limiterData = adapter.getLimiter(); + Limiter limiterData = adapter.getLimiter(); if (limiterData == null) return; String[] limiter = limiterData.names; @@ -477,27 +525,31 @@ public class LibraryActivity // a limiter view was clicked int i = (Integer)view.getTag(); - if (i == 1) { - // generate the artist limiter (we need to query the artist id) - MediaAdapter.Limiter limiter = mSongAdapter.getLimiter(); - Assert.assertEquals(MediaUtils.TYPE_ALBUM, limiter.type); - + Limiter limiter = mCurrentAdapter.getLimiter(); + int type = limiter.type; + if (i == 1 && type == MediaUtils.TYPE_ALBUM) { ContentResolver resolver = getContentResolver(); Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; String[] projection = new String[] { MediaStore.Audio.Media.ARTIST_ID }; - Cursor cursor = resolver.query(uri, projection, limiter.selection, null, null); + Cursor cursor = resolver.query(uri, projection, limiter.data.toString(), null, null); if (cursor != null) { if (cursor.moveToNext()) { - setLimiter(mArtistAdapter.getLimiter(cursor.getLong(0))); - updateLimiterViews(); - cursor.close(); - return; + setLimiter(mArtistAdapter.buildLimiter(cursor.getLong(0))); } cursor.close(); } + } else if (i > 0) { + Assert.assertEquals(MediaUtils.TYPE_FILE, limiter.type); + File file = (File)limiter.data; + int diff = limiter.names.length - i; + while (--diff != -1) { + file = file.getParentFile(); + } + setLimiter(FileSystemAdapter.buildLimiter(file)); + } else { + clearLimiter(type); } - setLimiter(null); updateLimiterViews(); } else { super.onClick(view); @@ -510,12 +562,25 @@ public class LibraryActivity * @param adapter The adapter that owns the view. * @param view The MediaView to build from. */ - private static Intent createClickIntent(MediaAdapter adapter, MediaView view) + private static Intent createClickIntent(LibraryAdapter adapter, MediaView view) { + int type = adapter.getMediaType(); + long id = view.getMediaId(); Intent intent = new Intent(); - intent.putExtra("type", adapter.getMediaType()); - intent.putExtra("id", view.getMediaId()); + intent.putExtra("type", type); + intent.putExtra("id", id); intent.putExtra("title", view.getTitle()); + if (type == MediaUtils.TYPE_FILE) { + File file = (File)adapter.getItem((int)id); + String path; + try { + path = file.getCanonicalPath(); + } catch (IOException e) { + path = file.getAbsolutePath(); + Log.e("VanillaMusic", "Failed to canonicalize path", e); + } + intent.putExtra("file", path); + } return intent; } @@ -523,7 +588,7 @@ public class LibraryActivity * Builds a media query based off the data stored in the given intent. * * @param intent An intent created with - * {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}. + * {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}. * @param empty If true, use the empty projection (only query id). * @param all If true query all songs in the adapter; otherwise query based * on the row selected. @@ -540,8 +605,10 @@ public class LibraryActivity long id = intent.getLongExtra("id", -1); QueryTask query; - if (all || id == MediaView.HEADER_ID) { - query = mAdapters[type - 1].buildSongQuery(projection); + if (type == MediaUtils.TYPE_FILE) { + query = MediaUtils.buildFileQuery(intent.getStringExtra("file"), projection); + } else if (all || id == MediaView.HEADER_ID) { + query = ((MediaAdapter)mAdapters[type - 1]).buildSongQuery(projection); query.setExtra(id); } else { query = MediaUtils.buildQuery(type, id, projection, null); @@ -570,7 +637,7 @@ public class LibraryActivity return; } - MediaAdapter adapter = (MediaAdapter)((ListView)listView).getAdapter(); + LibraryAdapter adapter = (LibraryAdapter)((ListView)listView).getAdapter(); MediaView view = (MediaView)((AdapterView.AdapterContextMenuInfo)absInfo).targetView; // Store view data in intent to avoid problems when the view data changes @@ -596,7 +663,7 @@ public class LibraryActivity menu.add(0, MENU_EDIT, 0, R.string.edit).setIntent(intent); } menu.addSubMenu(0, MENU_ADD_TO_PLAYLIST, 0, R.string.add_to_playlist).getItem().setIntent(intent); - if (adapter != mPlaylistAdapter && adapter != mSongAdapter) + if (view.hasRightBitmap()) menu.add(0, MENU_EXPAND, 0, R.string.expand).setIntent(intent); if (!isHeader) menu.add(0, MENU_DELETE, 0, R.string.delete).setIntent(intent); @@ -608,7 +675,7 @@ public class LibraryActivity * * @param playlistId The id of the playlist to add to. * @param intent An intent created with - * {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}. + * {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}. */ private void addToPlaylist(long playlistId, Intent intent) { @@ -635,22 +702,33 @@ public class LibraryActivity * informing the user of this. * * @param intent An intent created with - * {@link LibraryActivity#createClickIntent(MediaAdapter,MediaView)}. + * {@link LibraryActivity#createClickIntent(LibraryAdapter,MediaView)}. */ private void delete(Intent intent) { int type = intent.getIntExtra("type", 1); long id = intent.getLongExtra("id", -1); + String message = null; + Resources res = getResources(); - if (type == MediaUtils.TYPE_PLAYLIST) { + if (type == MediaUtils.TYPE_FILE) { + String file = intent.getStringExtra("file"); + boolean success = MediaUtils.deleteFile(new File(file)); + if (!success) { + message = res.getString(R.string.delete_file_failed, file); + } + } else if (type == MediaUtils.TYPE_PLAYLIST) { Playlist.deletePlaylist(getContentResolver(), id); - String message = getResources().getString(R.string.playlist_deleted, intent.getStringExtra("title")); - Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } else { int count = PlaybackService.get(this).deleteMedia(type, id); - String message = getResources().getQuantityString(R.plurals.deleted, count, count); - Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + message = res.getQuantityString(R.plurals.deleted, count, count); } + + if (message == null) { + message = res.getString(R.string.deleted, intent.getStringExtra("title")); + } + + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } @Override @@ -730,6 +808,13 @@ public class LibraryActivity return super.onCreateOptionsMenu(menu); } + @Override + public boolean onPrepareOptionsMenu(Menu menu) + { + menu.findItem(MENU_SORT).setEnabled(mCurrentAdapter != mFilesAdapter); + return super.onPrepareOptionsMenu(menu); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { @@ -741,7 +826,7 @@ public class LibraryActivity openPlaybackActivity(); return true; case MENU_SORT: { - MediaAdapter adapter = mCurrentAdapter; + MediaAdapter adapter = (MediaAdapter)mCurrentAdapter; int mode = adapter.getSortMode(); int check; if (mode < 0) { @@ -780,15 +865,13 @@ public class LibraryActivity /** * Hook up a ListView to this Activity and the supplied adapter * + * @param index Where to put the view and adapter in mLists/mAdapters. * @param id The id of the ListView - * @param type The media type for the adapter. + * @param adapter The adapter to hook up. * @param label The text to show on the tab. * @param icon The icon to show on the tab. - * @param expandable True if the rows are expandable. - * @param hasHeader True if the view should have a header row. - * @param limiter The initial limiter to set on the adapter. */ - private MediaAdapter setupView(int id, int type, int label, int icon, boolean expandable, boolean hasHeader, MediaAdapter.Limiter limiter) + private void setupView(int index, int id, int label, int icon, LibraryAdapter adapter) { ListView view = (ListView)findViewById(id); view.setOnItemClickListener(this); @@ -797,9 +880,9 @@ public class LibraryActivity view.setDivider(null); view.setFastScrollEnabled(true); - MediaAdapter adapter = new MediaAdapter(this, type, expandable, hasHeader, limiter); view.setAdapter(adapter); - loadSortOrder(adapter); + if (adapter instanceof MediaAdapter) + loadSortOrder((MediaAdapter)adapter); Resources res = getResources(); String labelRes = res.getString(label); @@ -809,7 +892,9 @@ public class LibraryActivity else mTabHost.addTab(mTabHost.newTabSpec(labelRes).setIndicator(labelRes, iconRes).setContent(id)); - return adapter; + mAdapters[index] = adapter; + mLists[index] = view; + mRequeryNeeded[index] = true; } /** @@ -840,7 +925,7 @@ public class LibraryActivity */ private static final int MSG_SAVE_SORT = 16; /** - * Call {@link LibraryActivity#requestRequery(MediaAdapter)} on the adapter + * Call {@link LibraryActivity#requestRequery(LibraryAdapter)} on the adapter * passed in obj. */ private static final int MSG_REQUEST_REQUERY = 17; @@ -877,16 +962,18 @@ public class LibraryActivity break; } case MSG_RUN_QUERY: { - final MediaAdapter adapter = (MediaAdapter)message.obj; - QueryTask query = adapter.buildQuery(); - final Cursor cursor = query.runQuery(getContentResolver()); + final LibraryAdapter adapter = (LibraryAdapter)message.obj; + final Object data = adapter.query(); runOnUiThread(new Runnable() { @Override public void run() { - adapter.changeCursor(cursor); + adapter.commitQuery(data); + // scroll to the top of the list + mLists[adapter.getMediaType() - 1].setSelection(0); } }); + mRequeryNeeded[adapter.getMediaType() - 1] = false; break; } case MSG_SAVE_SORT: { @@ -897,7 +984,7 @@ public class LibraryActivity break; } case MSG_REQUEST_REQUERY: - requestRequery((MediaAdapter)message.obj); + requestRequery((LibraryAdapter)message.obj); break; default: return super.handleMessage(message); @@ -913,15 +1000,15 @@ public class LibraryActivity * * Must be called on the UI thread. */ - public void requestRequery(MediaAdapter adapter) + public void requestRequery(LibraryAdapter adapter) { if (adapter == mCurrentAdapter) { runQuery(adapter); } else { - adapter.requestRequery(); + mRequeryNeeded[adapter.getMediaType() - 1] = true; // Clear the data for non-visible adapters (so we don't show the old // data briefly when we later switch to that adapter) - adapter.changeCursor(null); + adapter.clear(); } } @@ -930,7 +1017,7 @@ public class LibraryActivity * * @param adapter The adapter to run the query for. */ - private void runQuery(MediaAdapter adapter) + private void runQuery(LibraryAdapter adapter) { mHandler.removeMessages(MSG_RUN_QUERY, adapter); mHandler.sendMessage(mHandler.obtainMessage(MSG_RUN_QUERY, adapter)); @@ -939,12 +1026,23 @@ public class LibraryActivity @Override public void onMediaChange() { - Handler handler = mUiHandler; - for (MediaAdapter adapter : mAdapters) { - handler.sendMessage(handler.obtainMessage(MSG_REQUEST_REQUERY, adapter)); + for (LibraryAdapter adapter : mAdapters) { + postRequestRequery(adapter); } } + /** + * Call {@link LibraryActivity#requestRequery(LibraryAdapter)} on the UI + * thread. + * + * @param adapter The adapter, passed to requestRequery. + */ + public void postRequestRequery(LibraryAdapter adapter) + { + Handler handler = mUiHandler; + handler.sendMessage(handler.obtainMessage(MSG_REQUEST_REQUERY, adapter)); + } + private void setSearchBoxVisible(boolean visible) { mSearchBoxVisible = visible; @@ -1001,10 +1099,11 @@ public class LibraryActivity @Override public void onTabChanged(String tag) { - MediaAdapter adapter = mAdapters[mTabHost.getCurrentTab()]; + LibraryAdapter adapter = mAdapters[mTabHost.getCurrentTab()]; mCurrentAdapter = adapter; - if (adapter.isRequeryNeeded()) + if (mRequeryNeeded[adapter.getMediaType() - 1]) { runQuery(adapter); + } updateLimiterViews(); } @@ -1031,6 +1130,8 @@ public class LibraryActivity @Override public void onDismiss(DialogInterface dialog) { + MediaAdapter adapter = (MediaAdapter)mCurrentAdapter; + ListView list = ((AlertDialog)dialog).getListView(); // subtract 1 for header int which = list.getCheckedItemPosition() - 1; @@ -1039,7 +1140,6 @@ public class LibraryActivity if (group.getCheckedRadioButtonId() == R.id.descending) which = ~which; - MediaAdapter adapter = mCurrentAdapter; adapter.setSortMode(which); requestRequery(adapter); diff --git a/src/org/kreed/vanilla/LibraryAdapter.java b/src/org/kreed/vanilla/LibraryAdapter.java new file mode 100644 index 00000000..54455486 --- /dev/null +++ b/src/org/kreed/vanilla/LibraryAdapter.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2011 Christopher Eby + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.kreed.vanilla; + +import android.widget.ListAdapter; + +/** + * Provides support for limiters and a few other methods LibraryActivity uses + * for its adapters. + */ +public interface LibraryAdapter extends ListAdapter { + /** + * Return the type of media represented by this adapter. One of + * MediaUtils.TYPE_*. + */ + public int getMediaType(); + + /** + * Set the limiter for the adapter. + * + * A limiter is intended to restrict displayed media to only those that are + * children of a given parent media item. + * + * @param limiter The limiter, created by + * {@link LibraryAdapter#buildLimiter(long)}. + */ + public void setLimiter(Limiter limiter); + + /** + * Returns the limiter currently active on this adapter or null if none are + * active. + */ + public Limiter getLimiter(); + + /** + * Builds a limiter based off of the media represented by the given row. + * + * @param id The id of the row. + * @see LibraryAdapter#getLimiter() + * @see LibraryAdapter#setLimiter(Limiter) + */ + public Limiter buildLimiter(long id); + + /** + * Set a new filter. + * + * The data should be requeried after calling this. + * + * @param filter The terms to filter on, separated by spaces. Only + * media that contain all of the terms (in any order) will be displayed + * after filtering is complete. + */ + public void setFilter(String filter); + + /** + * Retrieve the data for this adapter. The data must be set with + * {@link LibraryAdapter#commitQuery(Object)} before it takes effect. + * + * This should be called on a worker thread. + * + * @return The data. Contents depend on the sub-class. + */ + public Object query(); + + /** + * Update the adapter with the given data. + * + * Must be called on the UI thread. + * + * @param data Data from {@link LibraryAdapter#query()}. + */ + public void commitQuery(Object data); + + /** + * Clear the data for this adapter. + * + * Must be called on the UI thread. + */ + public void clear(); +} \ No newline at end of file diff --git a/src/org/kreed/vanilla/Limiter.java b/src/org/kreed/vanilla/Limiter.java new file mode 100644 index 00000000..c6a0016c --- /dev/null +++ b/src/org/kreed/vanilla/Limiter.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2011 Christopher Eby + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.kreed.vanilla; + +import java.io.Serializable; + +/** + * Limiter is a constraint for MediaAdapter and FileSystemAdapter used when + * a row is "expanded". + */ +public class Limiter implements Serializable { + private static final long serialVersionUID = -4729694243900202614L; + + /** + * The type of the limiter. One of MediaUtils.TYPE_ARTIST, TYPE_ALBUM, + * TYPE_GENRE, or TYPE_FILE. + */ + public final int type; + /** + * Each element will be given a separate view each representing a higher + * different limiters. The first element is the broadest limiter, the last + * the most specific. For example, an album limiter would look like: + * { "Some Artist", "Some Album" } + * Or a file limiter: + * { "sdcard", "Music", "folder" } + */ + public final String[] names; + /** + * The data for the limiter. This varies according to the type of the + * limiter. + */ + public final Object data; + + /** + * Create a limiter with the given data. All parameters initialize their + * corresponding fields in the class. + */ + public Limiter(int type, String[] names, Object data) + { + this.type = type; + this.names = names; + this.data = data; + } +} \ No newline at end of file diff --git a/src/org/kreed/vanilla/MediaAdapter.java b/src/org/kreed/vanilla/MediaAdapter.java index e6229d24..1589a1ac 100644 --- a/src/org/kreed/vanilla/MediaAdapter.java +++ b/src/org/kreed/vanilla/MediaAdapter.java @@ -32,7 +32,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.CursorAdapter; import android.widget.SectionIndexer; -import java.io.Serializable; import java.util.regex.Pattern; /** @@ -46,7 +45,7 @@ import java.util.regex.Pattern; * to a specific group to be displayed, e.g. only songs from a certain artist. * See getLimiter and setLimiter for details. */ -public class MediaAdapter extends CursorAdapter implements SectionIndexer { +public class MediaAdapter extends CursorAdapter implements SectionIndexer, LibraryAdapter { private static final Pattern SPACE_SPLIT = Pattern.compile("\\s+"); /** @@ -109,10 +108,6 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { * The sort order for use with buildSongQuery(). */ private String mSongSort; - /** - * True if the data is stale and the query should be re-run. - */ - private boolean mNeedsRequery; /** * The human-readable descriptions for each sort mode. */ @@ -152,7 +147,6 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { mHasHeader = hasHeader; mLimiter = limiter; mIndexer = new MusicAlphabetIndexer(1); - mNeedsRequery = true; switch (type) { case MediaUtils.TYPE_ARTIST: @@ -272,31 +266,7 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { notifyDataSetChanged(); } - /** - * Returns true if the data is stale and should be requeried. - */ - public boolean isRequeryNeeded() - { - return mNeedsRequery; - } - - /** - * Mark the current data as requiring a requery. - */ - public void requestRequery() - { - mNeedsRequery = true; - } - - /** - * Set a new filter. - * - * The data should be requeried after calling this. - * - * @param filter The terms to filter on, separated by spaces. Only - * media that contain all of the terms (in any order) will be displayed - * after filtering is complete. - */ + @Override public void setFilter(String filter) { mConstraint = filter; @@ -373,26 +343,28 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { if (limiter != null && limiter.type == MediaUtils.TYPE_GENRE) { // Genre is not standard metadata for MediaStore.Audio.Media. // We have to query it through a separate provider. : / - return MediaUtils.buildGenreQuery(limiter.id, projection, selection.toString(), selectionArgs, sort); + return MediaUtils.buildGenreQuery((Long)limiter.data, projection, selection.toString(), selectionArgs, sort); } else { if (limiter != null) { if (selection.length() != 0) selection.append(" AND "); - selection.append(limiter.selection); + selection.append(limiter.data); } return new QueryTask(mStore, projection, selection.toString(), selectionArgs, sort); } } - /** - * Build a query to populate the adapter with. The result should be set with - * changeCursor(). - */ - public QueryTask buildQuery() + @Override + public Object query() { - mNeedsRequery = false; - return buildQuery(mProjection, false); + return buildQuery(mProjection, false).runQuery(mContext.getContentResolver()); + } + + @Override + public void commitQuery(Object data) + { + changeCursor((Cursor)data); } /** @@ -413,50 +385,35 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { return query; } - /** - * Return the type of media represented by this adapter. One of - * MediaUtils.TYPE_*. - */ + @Override + public void clear() + { + changeCursor(null); + } + + @Override public int getMediaType() { return mType; } - /** - * Set the limiter for the adapter. - * - * A limiter is intended to restrict displayed media to only those that are - * children of a given parent media item. - * - * The data should be requeried after calling this. - * - * @param limiter The limiter, created by {@link MediaAdapter#getLimiter(long)}. - */ - public final void setLimiter(Limiter limiter) + @Override + public void setLimiter(Limiter limiter) { mLimiter = limiter; } - /** - * Returns the limiter currently active on this adapter or null if none are - * active. - */ - public final Limiter getLimiter() + @Override + public Limiter getLimiter() { return mLimiter; } - /** - * Builds a limiter based off of the media represented by the given row. - * - * @param id The id of the row. - * @see MediaAdapter#getLimiter() - * @see MediaAdapter#setLimiter(MediaAdapter.Limiter) - */ - public Limiter getLimiter(long id) + @Override + public Limiter buildLimiter(long id) { String[] fields; - String selection = null; + Object data; Cursor cursor = getCursor(); if (cursor == null) @@ -468,26 +425,23 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { } switch (mType) { - case MediaUtils.TYPE_ARTIST: { + case MediaUtils.TYPE_ARTIST: fields = new String[] { cursor.getString(1) }; - String field = MediaStore.Audio.Media.ARTIST_ID; - selection = String.format("%s=%d", field, id); + data = String.format("%s=%d", MediaStore.Audio.Media.ARTIST_ID, id); break; - } - case MediaUtils.TYPE_ALBUM: { + case MediaUtils.TYPE_ALBUM: fields = new String[] { cursor.getString(2), cursor.getString(1) }; - String field = MediaStore.Audio.Media.ALBUM_ID; - selection = String.format("%s=%d", field, id); + data = String.format("%s=%d", MediaStore.Audio.Media.ALBUM_ID, id); break; - } case MediaUtils.TYPE_GENRE: fields = new String[] { cursor.getString(1) }; + data = id; break; default: throw new IllegalStateException("getLimiter() is not supported for media type: " + mType); } - return new MediaAdapter.Limiter(id, mType, selection, fields); + return new Limiter(mType, fields, data); } @Override @@ -598,24 +552,4 @@ public class MediaAdapter extends CursorAdapter implements SectionIndexer { { return mSortMode; } - - /** - * Limiter is a constraint for MediaAdapters used when a row is "expanded". - */ - public static class Limiter implements Serializable { - private static final long serialVersionUID = -4729694243900202614L; - - public final String[] names; - public final long id; - public final int type; - public final String selection; - - public Limiter(long id, int type, String selection, String[] names) - { - this.type = type; - this.names = names; - this.id = id; - this.selection = selection; - } - } } diff --git a/src/org/kreed/vanilla/MediaUtils.java b/src/org/kreed/vanilla/MediaUtils.java index d4cf8155..5c69e1a7 100644 --- a/src/org/kreed/vanilla/MediaUtils.java +++ b/src/org/kreed/vanilla/MediaUtils.java @@ -24,14 +24,19 @@ package org.kreed.vanilla; import android.content.ContentResolver; import android.database.Cursor; +import android.database.DatabaseUtils; import android.net.Uri; import android.provider.MediaStore; +import java.io.File; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Random; import junit.framework.Assert; +/** + * Provides some static Song/MediaStore-related utility functions. + */ public class MediaUtils { /** * A special invalid media type. @@ -57,6 +62,11 @@ public class MediaUtils { * Type indicating ids represent genres. */ public static final int TYPE_GENRE = 5; + /** + * Special type for files and folders. Most methods do not accept this type + * since files have no MediaStore id and require special handling. + */ + public static final int TYPE_FILE = 6; /** * The default sort order for media queries. First artist, then album, then @@ -488,4 +498,42 @@ public class MediaUtils { return result; } + + /** + * Delete the given file or directory recursively. + * + * @return True if successful; false otherwise. + */ + public static boolean deleteFile(File file) + { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteFile(child); + } + } + return file.delete(); + } + + /** + * Build a query that will contain all the media under the given path. + * + * @param path The path, e.g. /mnt/sdcard/music/ + * @param projection The columns to query + * @return The initialized query. + */ + public static QueryTask buildFileQuery(String path, String[] projection) + { + // It would be better to use selectionArgs to pass path here, but there + // doesn't appear to be any way to pass the * when using it. + StringBuilder selection = new StringBuilder(); + selection.append("_data GLOB "); + DatabaseUtils.appendEscapedSQLString(selection, path); + // delete the quotation mark added by the escape method + selection.deleteCharAt(selection.length() - 1); + selection.append("*' AND is_music!=0"); + + Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + return new QueryTask(media, projection, selection.toString(), null, DEFAULT_SORT); + } } diff --git a/src/org/kreed/vanilla/MediaView.java b/src/org/kreed/vanilla/MediaView.java index 28840e87..738292c7 100644 --- a/src/org/kreed/vanilla/MediaView.java +++ b/src/org/kreed/vanilla/MediaView.java @@ -265,6 +265,14 @@ public final class MediaView extends View { return mTitle; } + /** + * Returns true if the view has a right bitmap that is visible. + */ + public boolean hasRightBitmap() + { + return mRightBitmap != null && mShowBitmaps; + } + /** * Returns true if the right bitmap was pressed in the last touch event. */ @@ -305,6 +313,19 @@ public final class MediaView extends View { invalidate(); } + /** + * Set the id and title in this view. + * + * @param id The new id. + * @param title The new title for the view. + */ + public void setData(long id, String title) + { + mId = id; + mTitle = title; + invalidate(); + } + /** * Update mExpanderPressed. */