001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2016, 2018 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.osgi.webconlet.upnpbrowser; 020 021import freemarker.core.ParseException; 022import freemarker.template.MalformedTemplateNameException; 023import freemarker.template.Template; 024import freemarker.template.TemplateNotFoundException; 025import java.io.IOException; 026import java.io.InputStreamReader; 027import java.io.Reader; 028import java.time.Instant; 029import java.util.ArrayList; 030import java.util.Arrays; 031import java.util.Collections; 032import java.util.Comparator; 033import java.util.HashMap; 034import java.util.HashSet; 035import java.util.List; 036import java.util.Map; 037import java.util.Optional; 038import java.util.Set; 039import java.util.stream.Collectors; 040import org.jdrupes.httpcodec.types.Converters; 041import org.jdrupes.httpcodec.types.MediaType; 042import org.jgrapes.core.Channel; 043import org.jgrapes.core.Event; 044import org.jgrapes.core.Manager; 045import org.jgrapes.core.annotation.Handler; 046import org.jgrapes.io.IOSubchannel; 047import org.jgrapes.webconsole.base.Conlet.RenderMode; 048import org.jgrapes.webconsole.base.ConletBaseModel; 049import org.jgrapes.webconsole.base.ConsoleConnection; 050import org.jgrapes.webconsole.base.RenderSupport; 051import org.jgrapes.webconsole.base.ResourceByInputStream; 052import org.jgrapes.webconsole.base.ResourceNotModified; 053import org.jgrapes.webconsole.base.WebConsoleUtils; 054import org.jgrapes.webconsole.base.events.AddConletRequest; 055import org.jgrapes.webconsole.base.events.AddConletType; 056import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; 057import org.jgrapes.webconsole.base.events.ConletResourceRequest; 058import org.jgrapes.webconsole.base.events.ConsoleReady; 059import org.jgrapes.webconsole.base.events.NotifyConletView; 060import org.jgrapes.webconsole.base.events.RenderConlet; 061import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 062import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 063import org.osgi.framework.BundleContext; 064import org.osgi.framework.Constants; 065import org.osgi.framework.InvalidSyntaxException; 066import org.osgi.framework.ServiceEvent; 067import org.osgi.framework.ServiceListener; 068import org.osgi.framework.ServiceReference; 069import org.osgi.service.component.runtime.ServiceComponentRuntime; 070import org.osgi.service.upnp.UPnPDevice; 071import org.osgi.service.upnp.UPnPIcon; 072 073/** 074 * A conlet for inspecting the services in an OSGi runtime. 075 */ 076@SuppressWarnings({ "PMD.ExcessiveImports" }) 077public class UPnPBrowserConlet 078 extends FreeMarkerConlet<UPnPBrowserConlet.UPnPBrowserModel> 079 implements ServiceListener { 080 081 private static final Set<RenderMode> MODES = RenderMode.asSet( 082 RenderMode.Preview, RenderMode.View); 083 private final BundleContext context; 084 085 /** 086 * Creates a new component with its channel set to the given channel. 087 * 088 * @param componentChannel the channel that the component's handlers listen 089 * on by default and that {@link Manager#fire(Event, Channel...)} 090 * sends the event to 091 */ 092 @SuppressWarnings("PMD.UnusedFormalParameter") 093 public UPnPBrowserConlet(Channel componentChannel, BundleContext context, 094 ServiceComponentRuntime scr) { 095 super(componentChannel); 096 this.context = context; 097 } 098 099 /** 100 * On {@link ConsoleReady}, fire the {@link AddConletType}. 101 * 102 * @param event the event 103 * @param channel the channel 104 * @throws TemplateNotFoundException the template not found exception 105 * @throws MalformedTemplateNameException the malformed template name 106 * exception 107 * @throws ParseException the parse exception 108 * @throws IOException Signals that an I/O exception has occurred. 109 */ 110 @Handler 111 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 112 throws TemplateNotFoundException, MalformedTemplateNameException, 113 ParseException, IOException { 114 @SuppressWarnings("PMD.CloseResource") 115 Reader deviceTemplate = new InputStreamReader(UPnPBrowserConlet.class 116 .getResourceAsStream("device-tree-template.html")); 117 // Add conlet resources to page 118 channel.respond(new AddConletType(type()) 119 .addRenderMode(RenderMode.Preview).setDisplayNames( 120 localizations(channel.supportedLocales(), "conletName")) 121 .addScript(new ScriptResource() 122 .setScriptUri(event.renderSupport().conletResource( 123 type(), "UPnPBrowser-functions.ftl.js")) 124 .setScriptType("module")) 125 .addScript( 126 new ScriptResource() 127 .setScriptId("upnpbrowser-device-tree-template") 128 .setScriptType("text/x-template") 129 .loadScriptSource(deviceTemplate)) 130 .addCss(event.renderSupport(), 131 WebConsoleUtils.uriFromPath("UPnPBrowser-style.css"))); 132 } 133 134 @Override 135 protected Optional<UPnPBrowserModel> createNewState(AddConletRequest event, 136 ConsoleConnection session, String conletId) throws Exception { 137 return Optional.of(new UPnPBrowserModel(conletId)); 138 } 139 140 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 141 @Override 142 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 143 ConsoleConnection channel, String conletId, 144 UPnPBrowserModel conletState) throws Exception { 145 Set<RenderMode> renderedAs = new HashSet<>(); 146 if (event.renderAs().contains(RenderMode.Preview)) { 147 Template tpl = freemarkerConfig() 148 .getTemplate("UPnPBrowser-preview.ftl.html"); 149 channel.respond(new RenderConlet(type(), conletId, 150 processTemplate(event, tpl, 151 fmModel(event, channel, conletId, conletState))) 152 .setRenderAs( 153 RenderMode.Preview.addModifiers(event.renderAs())) 154 .setSupportedModes(MODES)); 155 List<Map<String, Object>> deviceInfos = Arrays.stream( 156 context.getAllServiceReferences(UPnPDevice.class.getName(), 157 "(!(" + UPnPDevice.PARENT_UDN + "=*))")) 158 .map(svc -> createDeviceInfo(context, 159 (ServiceReference<UPnPDevice>) svc, event.renderSupport())) 160 .collect(Collectors.toList()); 161 channel.respond(new NotifyConletView(type(), 162 conletId, "deviceUpdates", deviceInfos, "preview", true)); 163 renderedAs.add(RenderMode.Preview); 164 } 165 if (event.renderAs().contains(RenderMode.View)) { 166 Template tpl 167 = freemarkerConfig().getTemplate("UPnPBrowser-view.ftl.html"); 168 channel.respond(new RenderConlet(type(), conletId, 169 processTemplate(event, tpl, 170 fmModel(event, channel, conletId, conletState))) 171 .setRenderAs( 172 RenderMode.View.addModifiers(event.renderAs())) 173 .setSupportedModes(MODES)); 174 @SuppressWarnings("PMD.UseConcurrentHashMap") 175 Map<String, Map<String, Object>> deviceInfos = new HashMap<>(); 176 Arrays.stream(context 177 .getAllServiceReferences(UPnPDevice.class.getName(), null)) 178 .map(svc -> createDeviceInfo(context, 179 (ServiceReference<UPnPDevice>) svc, event.renderSupport())) 180 .forEach(devInfo -> deviceInfos.put((String) devInfo.get("udn"), 181 devInfo)); 182 channel.respond(new NotifyConletView(type(), 183 conletId, "deviceUpdates", treeify(deviceInfos), "view", true)); 184 renderedAs.add(RenderMode.View); 185 } 186 return renderedAs; 187 } 188 189 @SuppressWarnings({ "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" }) 190 private Map<String, Object> createDeviceInfo(BundleContext context, 191 ServiceReference<UPnPDevice> deviceRef, 192 RenderSupport renderSupport) { 193 UPnPDevice device = context.getService(deviceRef); 194 if (device == null) { 195 return null; 196 } 197 try { 198 @SuppressWarnings("PMD.UseConcurrentHashMap") 199 Map<String, Object> result = new HashMap<>(); 200 result.put("udn", (String) deviceRef.getProperty(UPnPDevice.UDN)); 201 result.computeIfAbsent("parentUdn", 202 k -> (String) deviceRef.getProperty(UPnPDevice.PARENT_UDN)); 203 result.put("friendlyName", 204 deviceRef.getProperty(UPnPDevice.FRIENDLY_NAME)); 205 if (device.getIcons(null) != null) { 206 result.put("iconUrl", WebConsoleUtils.mergeQuery( 207 renderSupport.conletResource(type(), ""), 208 Map.of("udn", (String) deviceRef 209 .getProperty(UPnPDevice.UDN), "resource", "icon")) 210 .toASCIIString()); 211 } 212 return result; 213 } finally { 214 context.ungetService(deviceRef); 215 } 216 } 217 218 @SuppressWarnings({ "unchecked", "PMD.AvoidInstantiatingObjectsInLoops" }) 219 private List<Map<String, Object>> 220 treeify(Map<String, Map<String, Object>> deviceInfos) { 221 for (Map.Entry<String, Map<String, Object>> e : deviceInfos 222 .entrySet()) { 223 Optional.ofNullable(e.getValue().get("parentUdn")).ifPresent( 224 parentUdn -> ((List<Map<String, Object>>) deviceInfos 225 .get(parentUdn).computeIfAbsent("childDevices", 226 k -> new ArrayList<Map<String, Object>>())) 227 .add(e.getValue())); 228 } 229 return deviceInfos.values().stream() 230 .filter(deviceInfo -> !deviceInfo.containsKey("parentUdn")) 231 .collect(Collectors.toList()); 232 } 233 234 @Override 235 @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" }) 236 protected void doGetResource(ConletResourceRequest event, 237 IOSubchannel channel) { 238 Map<String, List<String>> query 239 = WebConsoleUtils.queryAsMap(event.resourceUri()); 240 if (!query.containsKey("udn")) { 241 super.doGetResource(event, channel); 242 return; 243 } 244 try { 245 Arrays.stream(context.getAllServiceReferences(null, 246 String.format("(&(%s=%s)(%s=%s))", Constants.OBJECTCLASS, 247 UPnPDevice.class.getName(), UPnPDevice.UDN, 248 query.get("udn").get(0)))) 249 .findFirst().ifPresent(deviceRef -> { 250 @SuppressWarnings("unchecked") 251 UPnPDevice device = context 252 .getService((ServiceReference<UPnPDevice>) deviceRef); 253 if (query.getOrDefault("resource", Collections.emptyList()) 254 .contains("icon")) { 255 provideIcon(event, device); 256 } 257 }); 258 } catch (InvalidSyntaxException e) { 259 throw new IllegalArgumentException(e); 260 } 261 } 262 263 @SuppressWarnings({ "PMD.EmptyCatchBlock", "PMD.DataflowAnomalyAnalysis" }) 264 private void provideIcon(ConletResourceRequest event, UPnPDevice device) { 265 UPnPIcon[] icons = device 266 .getIcons(event.session().locale().toLanguageTag()); 267 if (icons == null) { 268 icons = device.getIcons(null); 269 } 270 if (icons == null) { 271 return; 272 } 273 Arrays.sort(icons, 274 Comparator.comparingInt(UPnPIcon::getHeight).reversed()); 275 UPnPIcon icon = icons[0]; 276 try { 277 if (event.ifModifiedSince().isPresent()) { 278 event.setResult(new ResourceNotModified(event, Instant.now(), 279 365 * 24 * 3600)); 280 } else { 281 MediaType mediaType = null; 282 if (icon.getMimeType() != null 283 && !icon.getMimeType().isEmpty()) { 284 mediaType 285 = Converters.MEDIA_TYPE 286 .fromFieldValue(icon.getMimeType()); 287 } 288 event.setResult(new ResourceByInputStream(event, 289 icon.getInputStream(), mediaType, Instant.now(), 290 365 * 24 * 3600)); 291 } 292 event.stop(); 293 } catch (IOException | java.text.ParseException e) { 294 // Handle as if no match 295 } 296 } 297 298 /** 299 * Translates the OSGi {@link ServiceEvent} to a JGrapes event and fires it 300 * on all known console session channels. 301 * 302 * @param event the event 303 */ 304 @Override 305 public void serviceChanged(ServiceEvent event) { 306 // TODO 307 } 308 309 /** 310 * The conlet's model. 311 */ 312 @SuppressWarnings("serial") 313 public class UPnPBrowserModel extends ConletBaseModel { 314 315 /** 316 * Instantiates a new service list model. 317 * 318 * @param conletId the conlet id 319 */ 320 public UPnPBrowserModel(String conletId) { 321 super(conletId); 322 } 323 324 } 325}