/********************************************************************************** * $URL: https://source.sakaiproject.org/svn/podcasts/trunk/podcasts-impl/impl/src/java/org/sakaiproject/component/app/podcasts/BasicPodfeedService.java $ * $Id: BasicPodfeedService.java 120353 2013-02-21 15:58:11Z matthew.buckett@it.ox.ac.uk $ *********************************************************************************** * * Copyright (c) 2003, 2004, 2005, 2006, 2007, 2008 The Sakai Foundation * * Licensed under the Educational Community 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.opensource.org/licenses/ECL-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 org.sakaiproject.component.app.podcasts; import static org.sakaiproject.component.app.podcasts.Utilities.checkSet; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.sakaiproject.api.app.podcasts.PodcastPermissionsService; import org.sakaiproject.api.app.podcasts.PodcastService; import org.sakaiproject.api.app.podcasts.PodfeedService; import org.sakaiproject.api.app.podcasts.exception.PodcastException; import org.sakaiproject.authz.api.SecurityAdvisor; import org.sakaiproject.authz.api.SecurityService; import org.sakaiproject.component.cover.ServerConfigurationService; import org.sakaiproject.content.api.ContentCollection; import org.sakaiproject.content.api.ContentCollectionEdit; import org.sakaiproject.content.api.ContentResource; import org.sakaiproject.entity.api.Entity; import org.sakaiproject.entity.api.ResourceProperties; import org.sakaiproject.entity.api.ResourcePropertiesEdit; import org.sakaiproject.exception.IdUnusedException; import org.sakaiproject.exception.PermissionException; import org.sakaiproject.site.api.SiteService; import org.sakaiproject.util.ResourceLoader; import com.sun.syndication.feed.WireFeed; import com.sun.syndication.feed.module.DCModuleImpl; import com.sun.syndication.feed.module.itunes.EntryInformation; import com.sun.syndication.feed.module.itunes.EntryInformationImpl; import com.sun.syndication.feed.rss.Channel; import com.sun.syndication.feed.rss.Description; import com.sun.syndication.feed.rss.Enclosure; import com.sun.syndication.feed.rss.Guid; import com.sun.syndication.feed.rss.Item; import com.sun.syndication.io.FeedException; import com.sun.syndication.io.WireFeedOutput; public class BasicPodfeedService implements PodfeedService { /** MIME type for the global description of the feed **/ private static final String DESCRIPTION_CONTENT_TYPE = "text/plain"; /** The default feed type. Currently rss_2.0 **/ private static final String defaultFeedType = "rss_2.0"; /** The default language type. Currently 'en-us' **/ private static final String LANGUAGE = "en-us"; /** Used to get the global feed title which is a property of Podcasts folder **/ private final String PODFEED_TITLE = "podfeedTitle"; /** Used to grab the default feed title prefix */ private final String FEED_TITLE_STRING = "feed_title"; /** Used to get the global feed description which is a property of Podcasts folder **/ private final String PODFEED_DESCRIPTION = "podfeedDescription"; /** Used to pull copyright statement from sakai.properties file */ private final String FEED_COPYRIGHT_STATEMENT = "podfeed_copyrighttext"; /** Used to get the copyright statement if stored in Podcasts folder */ private final String PODFEED_COPYRIGHT = "feed_copyright"; /** Used to pull generator value from sakai.properties file */ private final String FEED_GENERATOR_STRING = "podfeed_generator"; /** Used to pull generator value from Podcasts folder */ private final String PODFEED_GENERATOR = "feed_generator"; /** Used to pull item author from sakai.properties file */ private final String FEED_ITEM_AUTHOR_STRING = "podfeed_author"; /** Used to get the default feed description pieces from the message bundle */ private final String FEED_DESC1_STRING = "feed_desc1"; private final String FEED_DESC2_STRING = "feed_desc2"; /** Used to pull message bundle */ private final String PODFEED_MESSAGE_BUNDLE = "org.sakaiproject.api.podcasts.bundle.Messages"; private ResourceLoader resbud = new ResourceLoader(PODFEED_MESSAGE_BUNDLE); private static final Log LOG = LogFactory.getLog(BasicPodfeedService.class); private PodcastService podcastService; private PodcastPermissionsService podcastPermissionsService; private SecurityService securityService; private SiteService siteService; /** * @param securityService * The securityService to set. */ public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } /** * @param podcastService * The podcastService to set. */ public void setPodcastService(PodcastService podcastService) { this.podcastService = podcastService; } /** * @param siteService * The siteService to set. */ public void setSiteService(SiteService siteService) { this.siteService = siteService; } public void setPodcastPermissionsService(PodcastPermissionsService podcastPermissionsService) { this.podcastPermissionsService = podcastPermissionsService; } public void init() { checkSet(podcastService, "podcastService"); checkSet(podcastPermissionsService, "podcastPermissionsService"); checkSet(securityService, "securityService"); checkSet(siteService, "siteService"); } /** * Gets the podcast folder collection ResourceProperties * * @param siteId * The site id whose podcast folder ResourceProperties wanted * * @return ResourceProperties * The ResourceProperties collection for the podcasts folder */ private ResourceProperties getPodcastCollectionProperties(String siteId) { ContentCollection contentCollection = null; ResourceProperties rp = null; try { enablePodfeedSecurityAdvisor(); contentCollection = podcastService.getContentCollection(siteId); rp = contentCollection.getProperties(); } catch (Exception e) { // catches IdUnusedException, PermissionException LOG.error(e.getMessage() + " attempting to get feed title (getting podcast folder) " + "for site: " + siteId + ". " + e.getMessage(), e); throw new PodcastException(e); } finally { securityService.popAdvisor(); } return rp; } /** * Returns the podfeed global title from content hosting via the podcastService * * @return String * The global podfeed title */ public String getPodfeedTitle() { return getPodfeedTitle(podcastService.getSiteId()); } /** * Gets the title for the feed from podcast folder's properties. * * @param siteId * The site id * * @return String * The global podfeed title */ public String getPodfeedTitle(String siteId) { String feedTitle = null; try { ResourceProperties rp = getPodcastCollectionProperties(siteId); feedTitle = rp.getProperty(PODFEED_TITLE); /* For site where not added to folder upon creation * and has not been revised/updated */ if (feedTitle == null) { feedTitle = siteService.getSite(siteId).getTitle() + getMessageBundleString(FEED_TITLE_STRING); LOG.info("No saved feed title found for site: " + siteId + ". Using " + feedTitle); } } catch (IdUnusedException e) { LOG.error("IdUnusedException attempting to get feed title (getting podcast folder) " + "for site: " + siteId + ". " + e.getMessage(), e); throw new PodcastException(e); } return feedTitle; } /** * Stores the title for the feed in the podcast folder's resources * * @param String * The title for the feed */ public void setPodfeedTitle(String feedTitle) { setPodfeedTitle(feedTitle, podcastService.getSiteId()); } /** * Stores the title for the feed in the podcast folder's resources. Used by * the actual feed so need to pass in the siteId also. * * @param feedTitle * The title for the feed * @param siteId * The siteId whose feed is being titled */ public void setPodfeedTitle(String feedTitle, String siteId) { storeProperty(PODFEED_TITLE, feedTitle, siteId); } /** * Returns the String of the global feed description */ public String getPodfeedDescription() { return getPodfeedDescription(podcastService.getSiteId()); } /** * Returns the global feed description. * * @param siteId * The site id to get the feed description from * * @return String * The global feed description */ public String getPodfeedDescription(String siteId) { String feedDescription = null; try { ResourceProperties rp = getPodcastCollectionProperties(siteId); feedDescription = rp.getProperty(PODFEED_DESCRIPTION); /* For site where not added to folder upon creation * and has not been revised/updated */ if (feedDescription == null) { feedDescription = siteService.getSite(siteId).getTitle() + getMessageBundleString(FEED_DESC1_STRING) + getMessageBundleString(FEED_DESC2_STRING); LOG.info("No feed description found for site: " + siteId + ". Using " + feedDescription); } } catch (IdUnusedException e) { LOG.error("IdUnusedException attempting to get feed title (getting podcast folder) " + "for site: " + siteId + ". " + e.getMessage(), e); throw new PodcastException(e); } return feedDescription; } /** * Returns the global feed generator String. */ public void setPodfeedDescription(String feedDescription) { setPodfeedDescription(feedDescription, podcastService.getSiteId()); } /** * Sets the description for the feed. * * @param feedDescription * The String containing the feed description. * @param siteId * The siteId where to store the description */ public void setPodfeedDescription(String feedDescription, String siteId) { storeProperty(PODFEED_DESCRIPTION, feedDescription, siteId); } public String getPodfeedGenerator() { return getPodfeedGenerator(podcastService.getSiteId()); } /** * Returns the global feed generator string. * * @param siteId * The site id to get the feed description from * * @return String * The global feed generator string. */ public String getPodfeedGenerator(String siteId) { // Generator consists of 3 parts, first 2 pulled from sakai.properties: // ui.service - institution name // version.service - version number for the instance // last part is url of this instance final String localSakaiName = ServerConfigurationService.getString("ui.service","Sakai"); //localsakainame final String versionNumber = ServerConfigurationService.getString("version.service", "?");//dev final String portalUrl = ServerConfigurationService.getPortalUrl(); //last part exactly http:// final String generatorString = localSakaiName + " " + versionNumber + " " + portalUrl.substring(0, portalUrl.lastIndexOf("/")+1); return generatorString; } /** * */ public void setPodfeedGenerator(String feedGenerator) { setPodfeedGenerator(feedGenerator, podcastService.getSiteId()); } /** * Sets the description for the feed. * * @param feedDescription * The String containing the feed description. * @param siteId * The siteId where to store the description */ public void setPodfeedGenerator(String feedGenerator, String siteId) { storeProperty(PODFEED_GENERATOR, feedGenerator, siteId); } public String getPodfeedCopyright() { return getPodfeedCopyright(podcastService.getSiteId()); } /** * Returns the global feed generator string. * * @param siteId * The site id to get the feed description from * * @return String * The global feed generator string. */ public String getPodfeedCopyright(String siteId) { String currentCopyright=retrievePropValue(PODFEED_COPYRIGHT, siteId, FEED_COPYRIGHT_STATEMENT); Calendar rightNow = Calendar.getInstance(); int year = rightNow.get(Calendar.YEAR); Object[] arguments = { new Integer(year).toString() }; MessageFormat form = new MessageFormat(currentCopyright); String returnCopyright = form.format(arguments); return returnCopyright; } /** * Sets feed copyright statement from within site. */ public void setPodfeedCopyright(String feedCopyright) { setPodfeedCopyright(feedCopyright, podcastService.getSiteId()); } /** * Sets the description for the feed. * * @param feedDescription * The String containing the feed description. * @param siteId * The siteId where to store the description */ public void setPodfeedCopyright(String feedCopyright, String siteId) { storeProperty(PODFEED_COPYRIGHT, feedCopyright, siteId); } /** * Returns the property value for the property requested if stored within the * Podcasts folder resource of the site id passed in. If not stored, retrieves * the value from the Message bundle. * * @param propName * The name of the property wanted. * * @param siteId * The id of the site wanted. * * @param bundleName * The name within the Message bundle for the default string. * * @return * String containing either the stored property or the default Message bundle one. */ private String retrievePropValue(String propName, String siteId, String bundleName) { String propValue = null; ResourceProperties rp = getPodcastCollectionProperties(siteId); propValue = rp.getProperty(propName); /* For site where not added to folder upon creation * and has not been revised/updated */ if (propValue == null || "".equals(propValue)) { propValue = getMessageBundleString(bundleName); } return propValue; } /** * Stores the property propValue in the Podcasts folder resource under the name propName * * @param propName * The name within the resource to store the value * * @param propValue * The value to store * * @param siteId * Which site's Podcasts folder to store this property within. */ private void storeProperty(final String propName, String propValue, String siteId) { ContentCollectionEdit contentCollection = null; try { contentCollection = podcastService.getContentCollectionEditable(siteId); ResourcePropertiesEdit rp = contentCollection.getPropertiesEdit(); if (rp.getProperty(propName) != null) { rp.removeProperty(propName); } rp.addProperty(propName, propValue); podcastService.commitContentCollection(contentCollection); } catch (Exception e) { // catches IdUnusedException, PermissionException LOG.error(e.getMessage() + " attempting to add property " + propName + " for site: " + siteId + ". " + e.getMessage(), e); podcastService.cancelContentCollection(contentCollection); throw new PodcastException(e); } } /** * This method generates the RSS feed * * @return String * The feed XML file as a string */ public String generatePodcastRSS() { return generatePodcastRSS(podcastService.getSiteId(), null); } /** * This method generates the RSS feed * * @param siteID * The site id whose feed needs to be generated * @param ftyle * The feed type (for potential future development) - currently rss 2.0 * * @return String * The feed document as a String */ public String generatePodcastRSS(String siteId, String ftype) { final String feedType = (ftype != null) ? ftype : defaultFeedType; Date pubDate = null; Date lastBuildDate = null; // put each podcast entry/episode into a list List entries = populatePodcastArray(siteId); // Pull first entry if not null in order to establish publish date // for entire feed. Pull the second to get lastBuildDate if (entries != null) { Iterator iter = entries.iterator(); if (iter.hasNext()) { Item firstPodcast = (Item) iter.next(); pubDate = firstPodcast.getPubDate(); if (iter.hasNext()) { Item nextPodcast = (Item) iter.next(); lastBuildDate = nextPodcast.getPubDate(); } else { // only one, so use the first podcast date lastBuildDate = pubDate; } } else { // There are no podcasts to present, so use today pubDate = new Date(); lastBuildDate = pubDate; } } // pull global information for the feed into a Map so // can be passed all at once Map feedInfo = new HashMap(); feedInfo.put("title", getPodfeedTitle(siteId)); feedInfo.put("desc", getPodfeedDescription(siteId)); feedInfo.put("gen", getPodfeedGenerator(siteId)); // This is the URL for the actual feed. feedInfo.put("url", ServerConfigurationService.getServerUrl() + Entity.SEPARATOR + "podcasts/site/" + siteId); feedInfo.put("copyright", getPodfeedCopyright(siteId)); final WireFeed podcastFeed = doSyndication(feedInfo, entries, feedType, pubDate, lastBuildDate); final WireFeedOutput wireWriter = new WireFeedOutput(); try { return wireWriter.outputString(podcastFeed); } catch (FeedException e) { LOG.error( "Feed exception while attempting to write out the final xml file. " + "for site: " + siteId + ". " + e.getMessage(), e); throw new PodcastException(e); } } /** * This pulls the podcasts from Resourses and stuffs it in a list to be * added to the feed * * @param siteId * The site to pull the individual podcasts from * * @return List The list of podcast entries from ContentHosting */ private List populatePodcastArray(String siteId) { List podEntries = null; List entries = new ArrayList(); try { enablePodfeedSecurityAdvisor(); if (podcastService.isPodcastFolderHidden(siteId)) { return entries; } else { // get the individual podcasts podEntries = podcastService.getPodcasts(siteId); // remove any that user cannot access // need to popAdvisor since now group aware need to // check if need to filter podcasts based on group membership securityService.popAdvisor(); podEntries = podcastService.filterPodcasts(podEntries, siteId); } } catch (PermissionException e) { LOG.error("PermissionException getting podcasts in order to generate podfeed for site: " + siteId + ". " + e.getMessage(), e); throw new PodcastException(e); } catch (Exception e) { LOG.info(e.getMessage() + "for site: " + siteId, e); throw new PodcastException(e); } finally { securityService.popAdvisor(); } if (podEntries != null) { // get the iterator Iterator podcastIter = podEntries.iterator(); while (podcastIter.hasNext()) { // get its properties from ContentHosting ContentResource podcastResource = (ContentResource) podcastIter.next(); ResourceProperties podcastProperties = podcastResource.getProperties(); // publish date for this particular podcast // SAK-12052: need to compare for hidden using local time // then grab GMT time when storing for podcast feed Date publishDate = null; Date compareDate = null; try { if (podcastResource.getReleaseDate() != null) { compareDate = new Date(podcastResource.getReleaseDate().getTime()); publishDate = podcastService.getGMTdate(podcastResource.getReleaseDate().getTime()); } else { // need to put in GMT for the feed compareDate = new Date(podcastProperties.getTimeProperty(PodcastService.DISPLAY_DATE).getTime()); publishDate = podcastService.getGMTdate(podcastProperties.getTimeProperty(PodcastService.DISPLAY_DATE).getTime()); } } catch (Exception e) { // catches EntityPropertyNotDefinedException, EntityPropertyTypeException LOG.warn(e.getMessage() + " generating podfeed getting DISPLAY_DATE for entry for site: " + siteId + "while building feed. SKIPPING... " + e.getMessage(), e); } // if getting the date generates an error, skip this podcast. if (publishDate != null && ! hiddenInUI(podcastResource, compareDate)) { try { Map podcastMap = new HashMap(); podcastMap.put("date", publishDate); podcastMap.put("title", podcastProperties.getPropertyFormatted(ResourceProperties.PROP_DISPLAY_NAME)); enablePodfeedSecurityAdvisor(); String fileUrl = podcastService.getPodcastFileURL(podcastResource.getId()); podcastMap.put("guid", fileUrl); final String podcastFolderId = podcastService.retrievePodcastFolderId(siteId); securityService.popAdvisor(); // if site Display to Site, need to access actual podcasts thru Dav servlet // so change item URLs to do so if (!podcastService.isPublic(podcastFolderId)) { fileUrl = convertToDavUrl(fileUrl); } podcastMap.put("url", fileUrl); podcastMap.put("description",podcastProperties.getPropertyFormatted(ResourceProperties.PROP_DESCRIPTION)); podcastMap.put("author", podcastProperties.getPropertyFormatted(ResourceProperties.PROP_CREATOR)); podcastMap.put("len", Long.parseLong(podcastProperties.getProperty(ResourceProperties.PROP_CONTENT_LENGTH))); podcastMap.put("type", podcastProperties.getProperty(ResourceProperties.PROP_CONTENT_TYPE)); entries.add(addPodcast(podcastMap)); } catch (PermissionException e) { // Problem with this podcast file - LOG and skip LOG.error("PermissionException generating podfeed while adding entry for site: " + siteId + ". SKIPPING... " + e.getMessage(), e); } catch (IdUnusedException e) { // Problem with this podcast file - LOG and skip LOG.warn("IdUnusedException generating podfeed while adding entry for site: " + siteId + ". SKIPPING... " + e.getMessage(), e); } } } } securityService.popAdvisor(); return entries; } /** * This add a particular podcast to the feed. * * @param title * The title for this podcast * @param mp3link * The URL where the podcast is stored * @param date * The publish date for this podcast * @param blogContent * The description of this podcast * @param cat * The category of entry this is (Podcast) * @param author * The author of this podcast * * @return * A SyndEntryImpl for this podcast */ private Item addPodcast(Map values) { final Item item = new Item(); // set title for this podcast item.setTitle((String) values.get("title")); // Replace all occurrences of pattern (ie, spaces) in input // with hex equivalent (%20) Pattern pattern = Pattern.compile(" "); String url = (String) values.get("url"); Matcher matcher = pattern.matcher(url); url = matcher.replaceAll("%20"); item.setLink(url); // Set Publish date for this podcast/episode // NOTE: date has local time, but when feed rendered, // converts it to GMT item.setPubDate((Date) values.get("date")); // Set description for this podcast/episode final Description itemDescription = new Description(); itemDescription.setType(DESCRIPTION_CONTENT_TYPE); itemDescription.setValue((String) values.get("description")); item.setDescription(itemDescription); // Set guid for this podcast/episode item.setGuid(new Guid()); item.getGuid().setValue((String) values.get("guid")); item.getGuid().setPermaLink(false); // This creates the enclosure so podcatchers (iTunes) can // find the podcasts List enclosures = new ArrayList(); final Enclosure enc = new Enclosure(); enc.setUrl(url); enc.setType((String) values.get("type")); enc.setLength((Long) values.get("len")); enclosures.add(enc); item.setEnclosures(enclosures); // Currently uses 2 modules: // iTunes for podcasting // DCmodule since validators want email with author tag, // so use dc:creator instead List modules = new ArrayList(); // Generate the iTunes tags final EntryInformation iTunesModule = new EntryInformationImpl(); iTunesModule.setAuthor(getMessageBundleString(FEED_ITEM_AUTHOR_STRING)); iTunesModule.setSummary((String) values.get("description")); // Set dc:creator tag final DCModuleImpl dcModule = new DCModuleImpl(); dcModule.setCreator((String) values.get("author")); modules.add(iTunesModule); modules.add(dcModule); item.setModules(modules); return item; } /** * This puts the pieces together to generate the actual feed. * * @param title * The global title for the podcast * @param link * The URL for the feed * @param description_loc * Global description of the feed * @param copyright * Copyright information * @param entries * The list of individual podcasts * @param feedTyle * The output feed type (for potential future development). Set to rss_2.0 * @param pubDate * The date to set the publish date for this feed * * @eturn SyndFeed * The entire podcast stuffed into a SyndFeed object */ private Channel doSyndication(Map feedInfo, List entries, String feedType, Date pubDate, Date lastBuildDate) { final Channel channel = new Channel(); // FUTURE: How to determine what podcatcher supports and feed that to them channel.setFeedType(feedType); channel.setTitle((String) feedInfo.get("title")); channel.setLanguage(LANGUAGE); channel.setPubDate(pubDate); channel.setLastBuildDate(lastBuildDate); channel.setLink((String) feedInfo.get("url")); channel.setDescription((String) feedInfo.get("desc")); channel.setCopyright((String) feedInfo.get("copyright")); channel.setGenerator((String) feedInfo.get("gen")); channel.setItems(entries); // Used to remove the dc: tags from the channel level info List modules = new ArrayList(); channel.setModules(modules); return channel; } /** * Establish a security advisor to allow the "embedded" azg work to occur * with no need for additional security permissions. */ protected void enablePodfeedSecurityAdvisor() { // put in a security advisor so we can do our podcast work without need // of further permissions securityService.pushAdvisor(new SecurityAdvisor() { public SecurityAdvice isAllowed(String userId, String function, String reference) { return SecurityAdvice.ALLOWED; } }); } /** * Returns podcast folder id using either 'podcasts' or 'Podcasts'. If it * cannot find or is denied access, it will return null. * * @param siteId * The site to search * * @return String * Contains the complete id for the podcast folder */ public String retrievePodcastFolderId(String siteId) { String podcastFolderId = null; try { enablePodfeedSecurityAdvisor(); podcastFolderId = podcastService.retrievePodcastFolderId(siteId); } catch (PermissionException e) { // log and return null to indicate there was a problem generating LOG.error("PermissionException while trying to retrieve Podcast folder Id string " + "while generating feed for site " + siteId + e.getMessage(), e); } finally { securityService.popAdvisor(); } return podcastFolderId; } /** * If site is Display to Site, need to retrieve files thru dav servlet. * This converts a podcast URL to accomplish this. * * @param fileUrl * The current file URL. Access is through content. * * @return String * The changed URL that points to the dav servlet. */ private String convertToDavUrl(String fileUrl) { // Compile regular expression Pattern pattern = Pattern.compile("access/content/group"); // Replace all occurrences of pattern in input Matcher matcher = pattern.matcher(fileUrl); fileUrl = matcher.replaceAll("dav"); return fileUrl; } /** * Determines if authenticated user has 'read' access to podcast collection folder * * @param id * The id for the podcast collection folder * * @return * TRUE - has read access, FALSE - does not */ public boolean allowAccess(String id) { return podcastPermissionsService.allowAccess(id); } /** * Returns whether podcast should be 'hidden' in UI. Conditions: * Hidden property set * Release date is in future * Retract date is in past * * @param podcastResource * The actual podcast to check * @param tempDate * Release date (if exists) or display date (older version) */ private boolean hiddenInUI(ContentResource podcastResource, Date tempDate) { return podcastPermissionsService.isResourceHidden(podcastResource, tempDate); } /** * Sets the Faces error message by pulling the message from the * MessageBundle using the name passed in * * @param key * The name in the MessageBundle for the message wanted * * @return String * The string that is the value of the message */ private String getMessageBundleString(String key) { return resbud.getString(key); } }