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}