diff --git a/commons/res/drawable/media_info_shadow.xml b/commons/res/drawable/media_info_shadow.xml new file mode 100644 index 000000000..576f0d24e --- /dev/null +++ b/commons/res/drawable/media_info_shadow.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/commons/res/layout/detail_category_item.xml b/commons/res/layout/detail_category_item.xml new file mode 100644 index 000000000..385d13ddb --- /dev/null +++ b/commons/res/layout/detail_category_item.xml @@ -0,0 +1,12 @@ + + + diff --git a/commons/res/layout/detail_main_panel.xml b/commons/res/layout/detail_main_panel.xml new file mode 100644 index 000000000..dd6f9ae15 --- /dev/null +++ b/commons/res/layout/detail_main_panel.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/commons/res/layout/fragment_media_detail.xml b/commons/res/layout/fragment_media_detail.xml index 6a4bdbd6c..b1d566acd 100644 --- a/commons/res/layout/fragment_media_detail.xml +++ b/commons/res/layout/fragment_media_detail.xml @@ -28,31 +28,14 @@ android:scaleType="fitCenter" /> - - - - - + android:id="@+id/mediaDetailListView" + android:divider="#00A0A0A0" + android:fillViewport="true" + android:background="@android:color/transparent" + android:cacheColorHint="@android:color/transparent" + /> \ No newline at end of file diff --git a/commons/res/values-qq/strings.xml b/commons/res/values-qq/strings.xml index 956329ba4..e11b05916 100644 --- a/commons/res/values-qq/strings.xml +++ b/commons/res/values-qq/strings.xml @@ -89,4 +89,7 @@ Message asking user if they understand what kinds of images to upload. Button text for confirming the user understands what kinds of images to upload. {{Identical|Yes}} + Label for categories list in media detail panel + Placeholder for categories list in media detail panel, while loading from network. + Placeholder for categories list in media detail panel, if no categories found. diff --git a/commons/res/values/strings.xml b/commons/res/values/strings.xml index f3d4ac748..7bff4bb3a 100644 --- a/commons/res/values/strings.xml +++ b/commons/res/values/strings.xml @@ -102,4 +102,7 @@ Avoid copyrighted materials you found from the Internet as well as images of posters, book covers, etc. You think you got it? Yes! + Categories + Loading... + None selected diff --git a/commons/src/main/java/org/wikimedia/commons/CommonsApplication.java b/commons/src/main/java/org/wikimedia/commons/CommonsApplication.java index d41af879b..ecf5097bf 100644 --- a/commons/src/main/java/org/wikimedia/commons/CommonsApplication.java +++ b/commons/src/main/java/org/wikimedia/commons/CommonsApplication.java @@ -210,5 +210,4 @@ public class CommonsApplication extends Application { return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) || pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT); } - } diff --git a/commons/src/main/java/org/wikimedia/commons/Media.java b/commons/src/main/java/org/wikimedia/commons/Media.java index d300f3088..2d33c76ff 100644 --- a/commons/src/main/java/org/wikimedia/commons/Media.java +++ b/commons/src/main/java/org/wikimedia/commons/Media.java @@ -2,6 +2,7 @@ package org.wikimedia.commons; import android.net.Uri; import android.os.*; +import android.util.Log; import java.util.*; import java.util.regex.*; @@ -19,6 +20,8 @@ public class Media implements Parcelable { }; protected Media() { + this.categories = new ArrayList(); + this.descriptions = new HashMap(); } private HashMap tags = new HashMap(); @@ -127,10 +130,11 @@ public class Media implements Parcelable { this.license = license; } + // Primary metadata fields protected Uri localUri; protected String imageUrl; protected String filename; - protected String description; + protected String description; // monolingual description on input... protected long dataLength; protected Date dateCreated; protected Date dateUploaded; @@ -141,8 +145,45 @@ public class Media implements Parcelable { protected String creator; + protected ArrayList categories; // as loaded at runtime? + protected Map descriptions; // multilingual descriptions as loaded + + public ArrayList getCategories() { + return (ArrayList)categories.clone(); // feels dirty + } + + public void setCategories(List categories) { + this.categories.removeAll(this.categories); + this.categories.addAll(categories); + } + + public void setDescriptions(Map descriptions) { + for (String key : this.descriptions.keySet()) { + this.descriptions.remove(key); + } + for (String key : descriptions.keySet()) { + this.descriptions.put(key, descriptions.get(key)); + } + } + + public String getDescription(String preferredLanguage) { + if (descriptions.containsKey(preferredLanguage)) { + // See if the requested language is there. + return descriptions.get(preferredLanguage); + } else if (descriptions.containsKey("en")) { + // Ah, English. Language of the world, until the Chinese crush us. + return descriptions.get("en"); + } else if (descriptions.containsKey("default")) { + // No languages marked... + return descriptions.get("default"); + } else { + // FIXME: return the first available non-English description? + return ""; + } + } public Media(Uri localUri, String imageUrl, String filename, String description, long dataLength, Date dateCreated, Date dateUploaded, String creator) { + this(); this.localUri = localUri; this.imageUrl = imageUrl; this.filename = filename; @@ -170,6 +211,8 @@ public class Media implements Parcelable { parcel.writeInt(width); parcel.writeInt(height); parcel.writeString(license); + parcel.writeStringList(categories); + parcel.writeMap(descriptions); } public Media(Parcel in) { @@ -185,6 +228,8 @@ public class Media implements Parcelable { width = in.readInt(); height = in.readInt(); license = in.readString(); + in.readStringList(categories); + descriptions = in.readHashMap(ClassLoader.getSystemClassLoader()); } public void setDescription(String description) { diff --git a/commons/src/main/java/org/wikimedia/commons/MediaDataExtractor.java b/commons/src/main/java/org/wikimedia/commons/MediaDataExtractor.java new file mode 100644 index 000000000..04957b522 --- /dev/null +++ b/commons/src/main/java/org/wikimedia/commons/MediaDataExtractor.java @@ -0,0 +1,252 @@ +package org.wikimedia.commons; + +import android.util.Log; +import org.mediawiki.api.ApiResult; +import org.mediawiki.api.MWApi; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Fetch additional media data from the network that we don't store locally. + * + * This includes things like category lists and multilingual descriptions, + * which are not intrinsic to the media and may change due to editing. + */ +public class MediaDataExtractor { + private boolean fetched; + private boolean processed; + + private String filename; + private ArrayList categories; + private Map descriptions; + private String author; + private Date date; + + /** + * @param filename of the target media object, should include 'File:' prefix + */ + public MediaDataExtractor(String filename) { + this.filename = filename; + categories = new ArrayList(); + descriptions = new HashMap(); + fetched = false; + processed = false; + } + + /** + * Actually fetch the data over the network. + * todo: use local caching? + * + * Warning: synchronous i/o, call on a background thread + */ + public void fetch() throws IOException { + if (fetched) { + throw new IllegalStateException("Tried to call MediaDataExtractor.fetch() again."); + } + + MWApi api = CommonsApplication.createMWApi(); + ApiResult result = api.action("query") + .param("prop", "revisions") + .param("titles", filename) + .param("rvprop", "content") + .param("rvlimit", 1) + .param("rvgeneratexml", 1) + .get(); + + processResult(result); + fetched = true; + } + + private void processResult(ApiResult result) throws IOException { + + String wikiSource = result.getString("/api/query/pages/page/revisions/rev"); + String parseTreeXmlSource = result.getString("/api/query/pages/page/revisions/rev/@parsetree"); + + // In-page category links are extracted from source, as XML doesn't cover [[links]] + extractCategories(wikiSource); + + // Description template info is extracted from preprocessor XML + processWikiParseTree(parseTreeXmlSource); + } + + /** + * We could fetch all category links from API, but we actually only want the ones + * directly in the page source so they're editable. In the future this may change. + * + * @param source wikitext source code + */ + private void extractCategories(String source) { + Pattern regex = Pattern.compile("\\[\\[\\s*Category\\s*:([^]]*)\\s*\\]\\]", Pattern.CASE_INSENSITIVE); + Matcher matcher = regex.matcher(source); + while (matcher.find()) { + String cat = matcher.group(1).trim(); + categories.add(cat); + } + } + + private void processWikiParseTree(String source) throws IOException { + Document doc; + try { + DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + doc = docBuilder.parse(new ByteArrayInputStream(source.getBytes("UTF-8"))); + } catch (ParserConfigurationException e) { + throw new RuntimeException(e); + } catch (IllegalStateException e) { + throw new IOException(e); + } catch (SAXException e) { + throw new IOException(e); + } + Node templateNode = findTemplate(doc.getDocumentElement(), "information"); + if (templateNode != null) { + Node descriptionNode = findTemplateParameter(templateNode, "description"); + descriptions = getMultilingualText(descriptionNode); + + Node authorNode = findTemplateParameter(templateNode, "author"); + author = Utils.getStringFromDOM(authorNode); + } + } + + private Node findTemplate(Element parentNode, String title) throws IOException { + String ucTitle= Utils.capitalize(title); + NodeList nodes = parentNode.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeName().equals("template")) { + String foundTitle = getTemplateTitle(node); + if (Utils.capitalize(foundTitle).equals(ucTitle)) { + return node; + } + } + } + return null; + } + + private String getTemplateTitle(Node templateNode) throws IOException { + NodeList nodes = templateNode.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeName().equals("title")) { + return node.getTextContent().trim(); + } + } + throw new IOException("Template has no title element."); + } + + private static abstract class TemplateChildNodeComparator { + abstract public boolean match(Node node); + } + + private Node findTemplateParameter(Node templateNode, String name) throws IOException { + final String theName = name; + return findTemplateParameter(templateNode, new TemplateChildNodeComparator() { + @Override + public boolean match(Node node) { + return (Utils.capitalize(node.getTextContent().trim()).equals(Utils.capitalize(theName))); + } + }); + } + + private Node findTemplateParameter(Node templateNode, int index) throws IOException { + final String theIndex = "" + index; + return findTemplateParameter(templateNode, new TemplateChildNodeComparator() { + @Override + public boolean match(Node node) { + Element el = (Element)node; + if (el.getTextContent().trim().equals(theIndex)) { + return true; + } else if (el.getAttribute("index") != null && el.getAttribute("index").trim().equals(theIndex)) { + return true; + } else { + return false; + } + } + }); + } + + private Node findTemplateParameter(Node templateNode, TemplateChildNodeComparator comparator) throws IOException { + NodeList nodes = templateNode.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeName().equals("part")) { + NodeList childNodes = node.getChildNodes(); + for (int j = 0; j < childNodes.getLength(); j++) { + Node childNode = childNodes.item(j); + if (childNode.getNodeName().equals("name")) { + if (comparator.match(childNode)) { + // yay! Now fetch the value node. + for (int k = j + 1; k < childNodes.getLength(); k++) { + Node siblingNode = childNodes.item(k); + if (siblingNode.getNodeName().equals("value")) { + return siblingNode; + } + } + throw new IOException("No value node found for matched template parameter."); + } + } + } + } + } + throw new IOException("No matching template parameter node found."); + } + + // Extract a dictionary of multilingual texts from a subset of the parse tree. + // Texts are wrapped in things like {{en|foo} or {{en|1=foo bar}}. + // Text outside those wrappers is stuffed into a 'default' faux language key if present. + private Map getMultilingualText(Node parentNode) throws IOException { + Map texts = new HashMap(); + StringBuilder localText = new StringBuilder(); + + NodeList nodes = parentNode.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeName().equals("template")) { + // process a template node + String title = getTemplateTitle(node); + if (title.length() < 3) { + // Hopefully a language code. Nasty hack! + String lang = title; + Node valueNode = findTemplateParameter(node, 1); + String value = Utils.getStringFromDOM(valueNode); // hope there's no subtemplates or formatting for now + texts.put(lang, value); + } + } else if (node.getNodeType() == Node.TEXT_NODE) { + localText.append(node.getTextContent()); + } + } + + // Some descriptions don't list multilingual variants + String defaultText = localText.toString().trim(); + if (defaultText.length() > 0) { + texts.put("default", localText.toString()); + } + return texts; + } + + /** + * Take our metadata and inject it into a live Media object. + * Media object might contain stale or cached data, or emptiness. + * @param media + */ + public void fill(Media media) { + if (!fetched) { + throw new IllegalStateException("Tried to call MediaDataExtractor.fill() before fetch()."); + } + + media.setCategories(categories); + media.setDescriptions(descriptions); + + // add author, date, etc fields + } +} diff --git a/commons/src/main/java/org/wikimedia/commons/Utils.java b/commons/src/main/java/org/wikimedia/commons/Utils.java index b3623c10b..f3bea5a54 100644 --- a/commons/src/main/java/org/wikimedia/commons/Utils.java +++ b/commons/src/main/java/org/wikimedia/commons/Utils.java @@ -1,5 +1,6 @@ package org.wikimedia.commons; +import android.net.Uri; import android.os.*; import com.nostra13.universalimageloader.core.*; import com.nostra13.universalimageloader.core.assist.ImageScaleType; @@ -174,4 +175,23 @@ public class Utils { throw new RuntimeException("Unrecognized license value"); } + public static String implode(String glue, Iterable pieces) { + StringBuffer buffer = new StringBuffer(); + boolean first = true; + for (String piece : pieces) { + if (first) { + first = false; + } else { + buffer.append(glue); + } + buffer.append(pieces); + } + return buffer.toString(); + } + + public static Uri uriForWikiPage(String name) { + String underscored = name.trim().replace(" ", "_"); + String uriStr = CommonsApplication.HOME_URL + urlEncode(underscored); + return Uri.parse(uriStr); + } } diff --git a/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java b/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java index f0f5a24bf..e57ee7b24 100644 --- a/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java +++ b/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java @@ -204,6 +204,7 @@ public class Contribution extends Media { } public Contribution() { + super(); timestamp = new Date(System.currentTimeMillis()); } diff --git a/commons/src/main/java/org/wikimedia/commons/media/MediaDetailFragment.java b/commons/src/main/java/org/wikimedia/commons/media/MediaDetailFragment.java index 39343d825..e06d441b8 100644 --- a/commons/src/main/java/org/wikimedia/commons/media/MediaDetailFragment.java +++ b/commons/src/main/java/org/wikimedia/commons/media/MediaDetailFragment.java @@ -1,9 +1,12 @@ package org.wikimedia.commons.media; +import android.content.Intent; import android.graphics.*; +import android.net.Uri; import android.os.*; import android.text.*; import android.util.Log; +import android.util.TypedValue; import android.view.*; import android.widget.*; import com.actionbarsherlock.app.SherlockFragment; @@ -14,8 +17,13 @@ import com.nostra13.universalimageloader.core.assist.ImageLoadingListener; import com.android.volley.toolbox.*; +import org.mediawiki.api.ApiResult; +import org.mediawiki.api.MWApi; import org.wikimedia.commons.*; +import java.io.IOException; +import java.util.ArrayList; + public class MediaDetailFragment extends SherlockFragment { private boolean editable; @@ -33,6 +41,8 @@ public class MediaDetailFragment extends SherlockFragment { Bundle state = new Bundle(); state.putBoolean("editable", editable); state.putInt("index", index); + state.putInt("listIndex", 0); + state.putInt("listTop", 0); mf.setArguments(state); @@ -40,9 +50,22 @@ public class MediaDetailFragment extends SherlockFragment { } private ImageView image; - private EditText title; + //private EditText title; private ProgressBar loadingProgress; private ImageView loadingFailed; + private MediaDetailSpacer spacer; + private int initialListIndex = 0; + private int initialListTop = 0; + + private TextView title; + private TextView desc; + private ListView listView; + private ArrayList categoryNames; + private boolean categoriesLoaded = false; + private boolean categoriesPresent = false; + private ArrayAdapter categoryAdapter; + private ViewTreeObserver.OnGlobalLayoutListener observer; // for layout stuff, only used once! + private AsyncTask detailFetchTask; @Override @@ -50,6 +73,20 @@ public class MediaDetailFragment extends SherlockFragment { super.onSaveInstanceState(outState); outState.putInt("index", index); outState.putBoolean("editable", editable); + + getScrollPosition(); + outState.putInt("listIndex", initialListIndex); + outState.putInt("listTop", initialListTop); + } + + private void getScrollPosition() { + int initialListIndex = listView.getFirstVisiblePosition(); + View firstVisibleItem = listView.getChildAt(initialListIndex); + if (firstVisibleItem == null) { + initialListTop = 0; + } else { + initialListTop = firstVisibleItem.getTop(); + } } @Override @@ -59,19 +96,46 @@ public class MediaDetailFragment extends SherlockFragment { if(savedInstanceState != null) { editable = savedInstanceState.getBoolean("editable"); index = savedInstanceState.getInt("index"); + initialListIndex = savedInstanceState.getInt("listIndex"); + initialListTop = savedInstanceState.getInt("listTop"); } else { editable = getArguments().getBoolean("editable"); index = getArguments().getInt("index"); } final Media media = detailProvider.getMediaAtPosition(index); + categoryNames = new ArrayList(); + categoryNames.add(getString(R.string.detail_panel_cats_loading)); + + final View view = inflater.inflate(R.layout.fragment_media_detail, container, false); - View view = inflater.inflate(R.layout.fragment_media_detail, container, false); image = (ImageView) view.findViewById(R.id.mediaDetailImage); - title = (EditText) view.findViewById(R.id.mediaDetailTitle); loadingProgress = (ProgressBar) view.findViewById(R.id.mediaDetailImageLoading); loadingFailed = (ImageView) view.findViewById(R.id.mediaDetailImageFailed); + listView = (ListView) view.findViewById(R.id.mediaDetailListView); + + // Detail consists of a list view with main pane in header view, plus category list. + View detailView = getActivity().getLayoutInflater().inflate(R.layout.detail_main_panel, null, false); + listView.addHeaderView(detailView, null, false); + categoryAdapter = new ArrayAdapter(getActivity(), R.layout.detail_category_item, categoryNames); + listView.setAdapter(categoryAdapter); + listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + public void onItemClick(AdapterView adapterView, View view, int position, long id) { + if (categoriesLoaded && categoriesPresent) { + String selectedCategoryTitle = "Category:" + categoryNames.get(position - 1); + Intent viewIntent = new Intent(); + viewIntent.setAction(Intent.ACTION_VIEW); + viewIntent.setData(Utils.uriForWikiPage(selectedCategoryTitle)); + startActivity(viewIntent); + } + } + }); + + spacer = (MediaDetailSpacer) detailView.findViewById(R.id.mediaDetailSpacer); + title = (TextView) detailView.findViewById(R.id.mediaDetailTitle); + desc = (TextView) detailView.findViewById(R.id.mediaDetailDesc); // Enable or disable editing on the title + /* title.setClickable(editable); title.setFocusable(editable); title.setCursorVisible(editable); @@ -79,6 +143,8 @@ public class MediaDetailFragment extends SherlockFragment { if(!editable) { title.setBackgroundDrawable(null); } + */ + String actualUrl = TextUtils.isEmpty(media.getImageUrl()) ? media.getLocalUri().toString() : media.getThumbnailUrl(640); if(actualUrl.startsWith("http")) { @@ -88,6 +154,56 @@ public class MediaDetailFragment extends SherlockFragment { mwImage.setMedia(media, loader); Log.d("Volley", actualUrl); // FIXME: For transparent images + + // Load image metadata: desc, license, categories + // FIXME: keep the spinner going while we load data + // FIXME: cache this data + detailFetchTask = new AsyncTask() { + private MediaDataExtractor extractor; + + @Override + protected void onPreExecute() { + extractor = new MediaDataExtractor(media.getFilename()); + } + + @Override + protected Boolean doInBackground(Void... voids) { + try { + extractor.fetch(); + return Boolean.TRUE; + } catch (IOException e) { + e.printStackTrace(); + } + return Boolean.FALSE; + } + + @Override + protected void onPostExecute(Boolean success) { + detailFetchTask = null; + + if (success.booleanValue()) { + extractor.fill(media); + + // Fill some fields + desc.setText(media.getDescription("en")); + + categoryNames.removeAll(categoryNames); + categoryNames.addAll(media.getCategories()); + + categoriesLoaded = true; + categoriesPresent = (categoryNames.size() > 0); + if (!categoriesPresent) { + // Stick in a filler element. + categoryNames.add(getString(R.string.detail_panel_cats_none)); + } + + categoryAdapter.notifyDataSetChanged(); + } else { + Log.d("Commons", "Failed to load photo details."); + } + } + }; + Utils.executeAsyncTask(detailFetchTask); } else { com.nostra13.universalimageloader.core.ImageLoader.getInstance().displayImage(actualUrl, image, displayOptions, new ImageLoadingListener() { public void onLoadingStarted(String s, View view) { @@ -113,8 +229,11 @@ public class MediaDetailFragment extends SherlockFragment { } }); } - title.setText(media.getDisplayTitle()); + title.setText(media.getDisplayTitle()); + desc.setText(""); + + /* title.addTextChangedListener(new TextWatcher() { public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) { @@ -130,6 +249,35 @@ public class MediaDetailFragment extends SherlockFragment { } }); + */ + + // Layout observer to size the spacer item relative to the available space. + // There may be a .... better way to do this. + observer = new ViewTreeObserver.OnGlobalLayoutListener() { + private int currentHeight = -1; + + public void onGlobalLayout() { + int viewHeight = view.getHeight(); + //int textHeight = title.getLineHeight(); + int paddingDp = 48; + float paddingPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingDp, getResources().getDisplayMetrics()); + int newHeight = viewHeight - Math.round(paddingPx); + + if (newHeight != currentHeight) { + currentHeight = newHeight; + ViewGroup.LayoutParams params = spacer.getLayoutParams(); + params.height = newHeight; + spacer.setLayoutParams(params); + + // hack hack to trigger relayout + categoryAdapter.notifyDataSetChanged(); + + listView.setSelectionFromTop(initialListIndex, initialListTop); + } + + } + }; + view.getViewTreeObserver().addOnGlobalLayoutListener(observer); return view; } @@ -139,4 +287,17 @@ public class MediaDetailFragment extends SherlockFragment { displayOptions = Utils.getGenericDisplayOptions().build(); } + + @Override + public void onDestroyView() { + if (detailFetchTask != null) { + detailFetchTask.cancel(true); + detailFetchTask = null; + } + if (observer != null) { + getView().getViewTreeObserver().removeGlobalOnLayoutListener(observer); // old Android was on crack. CRACK IS WHACK + observer = null; + } + super.onDestroyView(); + } } diff --git a/commons/src/main/java/org/wikimedia/commons/media/MediaDetailSpacer.java b/commons/src/main/java/org/wikimedia/commons/media/MediaDetailSpacer.java new file mode 100644 index 000000000..49b048535 --- /dev/null +++ b/commons/src/main/java/org/wikimedia/commons/media/MediaDetailSpacer.java @@ -0,0 +1,19 @@ +package org.wikimedia.commons.media; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +public class MediaDetailSpacer extends View { + public MediaDetailSpacer(Context context) { + super(context); + } + + public MediaDetailSpacer(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public MediaDetailSpacer(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } +}