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.Components;
044import org.jgrapes.core.Event;
045import org.jgrapes.core.Manager;
046import org.jgrapes.core.annotation.Handler;
047import org.jgrapes.http.Session;
048import org.jgrapes.io.IOSubchannel;
049import org.jgrapes.webconsole.base.AbstractConlet;
050import org.jgrapes.webconsole.base.Conlet.RenderMode;
051import org.jgrapes.webconsole.base.ConsoleSession;
052import org.jgrapes.webconsole.base.RenderSupport;
053import org.jgrapes.webconsole.base.ResourceByInputStream;
054import org.jgrapes.webconsole.base.ResourceNotModified;
055import org.jgrapes.webconsole.base.WebConsoleUtils;
056import org.jgrapes.webconsole.base.events.AddConletRequest;
057import org.jgrapes.webconsole.base.events.AddConletType;
058import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
059import org.jgrapes.webconsole.base.events.ConletResourceRequest;
060import org.jgrapes.webconsole.base.events.ConsoleReady;
061import org.jgrapes.webconsole.base.events.NotifyConletView;
062import org.jgrapes.webconsole.base.events.RenderConletRequest;
063import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
064import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
065import org.osgi.framework.BundleContext;
066import org.osgi.framework.Constants;
067import org.osgi.framework.InvalidSyntaxException;
068import org.osgi.framework.ServiceEvent;
069import org.osgi.framework.ServiceListener;
070import org.osgi.framework.ServiceReference;
071import org.osgi.service.component.runtime.ServiceComponentRuntime;
072import org.osgi.service.upnp.UPnPDevice;
073import org.osgi.service.upnp.UPnPIcon;
074
075/**
076 * A conlet for inspecting the services in an OSGi runtime.
077 */
078@SuppressWarnings({ "PMD.ExcessiveImports" })
079public class UPnPBrowserConlet
080        extends FreeMarkerConlet<UPnPBrowserConlet.UPnPBrowserModel>
081        implements ServiceListener {
082
083    private static final Set<RenderMode> MODES = RenderMode.asSet(
084        RenderMode.Preview, RenderMode.View);
085    private final BundleContext context;
086
087    /**
088     * Creates a new component with its channel set to the given channel.
089     * 
090     * @param componentChannel the channel that the component's handlers listen
091     *            on by default and that {@link Manager#fire(Event, Channel...)}
092     *            sends the event to
093     */
094    @SuppressWarnings("PMD.UnusedFormalParameter")
095    public UPnPBrowserConlet(Channel componentChannel, BundleContext context,
096            ServiceComponentRuntime scr) {
097        super(componentChannel);
098        this.context = context;
099    }
100
101    /**
102     * On {@link ConsoleReady}, fire the {@link AddConletType}.
103     *
104     * @param event the event
105     * @param channel the channel
106     * @throws TemplateNotFoundException the template not found exception
107     * @throws MalformedTemplateNameException the malformed template name
108     *             exception
109     * @throws ParseException the parse exception
110     * @throws IOException Signals that an I/O exception has occurred.
111     */
112    @Handler
113    public void onConsoleReady(ConsoleReady event, ConsoleSession channel)
114            throws TemplateNotFoundException, MalformedTemplateNameException,
115            ParseException, IOException {
116        Reader deviceTemplate = new InputStreamReader(UPnPBrowserConlet.class
117            .getResourceAsStream("device-tree-template.html"));
118        // Add conlet resources to page
119        channel.respond(new AddConletType(type())
120            .setDisplayNames(
121                localizations(channel.supportedLocales(), "conletName"))
122            .addScript(new ScriptResource()
123                .setScriptUri(event.renderSupport().conletResource(
124                    type(), "UPnPBrowser-functions.ftl.js"))
125                .setScriptType("module"))
126            .addScript(
127                new ScriptResource()
128                    .setScriptId("upnpbrowser-device-tree-template")
129                    .setScriptType("text/x-template")
130                    .loadScriptSource(deviceTemplate))
131            .addCss(event.renderSupport(),
132                WebConsoleUtils.uriFromPath("UPnPBrowser-style.css")));
133    }
134
135    /*
136     * (non-Javadoc)
137     * 
138     * @see org.jgrapes.console.AbstractConlet#generateConletId()
139     */
140    @Override
141    protected String generateConletId() {
142        return type() + "-" + super.generateConletId();
143    }
144
145    /*
146     * (non-Javadoc)
147     * 
148     * @see org.jgrapes.console.AbstractConlet#modelFromSession
149     */
150    @SuppressWarnings("PMD.AvoidDuplicateLiterals")
151    @Override
152    protected Optional<UPnPBrowserModel> stateFromSession(
153            Session session, String conletId) {
154        if (conletId.startsWith(type() + "-")) {
155            return Optional.of(new UPnPBrowserModel(conletId));
156        }
157        return Optional.empty();
158    }
159
160    /*
161     * (non-Javadoc)
162     * 
163     * @see org.jgrapes.console.AbstractConlet#doAddConlet
164     */
165    @Override
166    protected ConletTrackingInfo doAddConlet(AddConletRequest event,
167            ConsoleSession channel)
168            throws Exception {
169        UPnPBrowserModel conletModel
170            = new UPnPBrowserModel(generateConletId());
171        return new ConletTrackingInfo(conletModel.getConletId())
172            .addModes(renderConlet(event, channel, conletModel));
173    }
174
175    /*
176     * (non-Javadoc)
177     * 
178     * @see org.jgrapes.console.AbstractConlet#doRenderConlet
179     */
180    @Override
181    protected Set<RenderMode> doRenderConlet(RenderConletRequest event,
182            ConsoleSession channel, String conletId,
183            UPnPBrowserModel conletModel)
184            throws Exception {
185        return renderConlet(event, channel, conletModel);
186    }
187
188    @SuppressWarnings({ "PMD.AvoidDuplicateLiterals",
189        "PMD.DataflowAnomalyAnalysis", "unchecked" })
190    private Set<RenderMode> renderConlet(RenderConletRequestBase<?> event,
191            ConsoleSession channel, UPnPBrowserModel conletModel)
192            throws TemplateNotFoundException,
193            MalformedTemplateNameException, ParseException, IOException,
194            InvalidSyntaxException {
195        Set<RenderMode> renderedAs = new HashSet<>();
196        if (event.renderAs().contains(RenderMode.Preview)) {
197            Template tpl = freemarkerConfig()
198                .getTemplate("UPnPBrowser-preview.ftl.html");
199            channel.respond(new RenderConletFromTemplate(event,
200                type(), conletModel.getConletId(), tpl,
201                fmModel(event, channel, conletModel))
202                    .setRenderAs(
203                        RenderMode.Preview.addModifiers(event.renderAs()))
204                    .setSupportedModes(MODES));
205            List<Map<String, Object>> deviceInfos = Arrays.stream(
206                context.getAllServiceReferences(UPnPDevice.class.getName(),
207                    "(!(" + UPnPDevice.PARENT_UDN + "=*))"))
208                .map(svc -> createDeviceInfo(context,
209                    (ServiceReference<UPnPDevice>) svc, event.renderSupport()))
210                .collect(Collectors.toList());
211            channel.respond(new NotifyConletView(type(),
212                conletModel.getConletId(), "deviceUpdates", deviceInfos,
213                "preview", true));
214            renderedAs.add(RenderMode.Preview);
215        }
216        if (event.renderAs().contains(RenderMode.View)) {
217            Template tpl
218                = freemarkerConfig().getTemplate("UPnPBrowser-view.ftl.html");
219            channel.respond(new RenderConletFromTemplate(event,
220                type(), conletModel.getConletId(), tpl,
221                fmModel(event, channel, conletModel))
222                    .setRenderAs(
223                        RenderMode.View.addModifiers(event.renderAs()))
224                    .setSupportedModes(MODES));
225            @SuppressWarnings("PMD.UseConcurrentHashMap")
226            Map<String, Map<String, Object>> deviceInfos = new HashMap<>();
227            Arrays.stream(context
228                .getAllServiceReferences(UPnPDevice.class.getName(), null))
229                .map(svc -> createDeviceInfo(context,
230                    (ServiceReference<UPnPDevice>) svc, event.renderSupport()))
231                .forEach(devInfo -> deviceInfos.put((String) devInfo.get("udn"),
232                    devInfo));
233            channel.respond(new NotifyConletView(type(),
234                conletModel.getConletId(), "deviceUpdates",
235                treeify(deviceInfos), "view", true));
236            renderedAs.add(RenderMode.View);
237        }
238        return renderedAs;
239    }
240
241    @SuppressWarnings({ "PMD.NcssCount", "PMD.AvoidDuplicateLiterals" })
242    private Map<String, Object> createDeviceInfo(BundleContext context,
243            ServiceReference<UPnPDevice> deviceRef,
244            RenderSupport renderSupport) {
245        UPnPDevice device = context.getService(deviceRef);
246        if (device == null) {
247            return null;
248        }
249        try {
250            @SuppressWarnings("PMD.UseConcurrentHashMap")
251            Map<String, Object> result = new HashMap<>();
252            result.put("udn", (String) deviceRef.getProperty(UPnPDevice.UDN));
253            result.computeIfAbsent("parentUdn",
254                k -> (String) deviceRef.getProperty(UPnPDevice.PARENT_UDN));
255            result.put("friendlyName",
256                deviceRef.getProperty(UPnPDevice.FRIENDLY_NAME));
257            if (device.getIcons(null) != null) {
258                result.put("iconUrl", WebConsoleUtils.mergeQuery(
259                    renderSupport.conletResource(type(), ""),
260                    Components.mapOf("udn", (String) deviceRef
261                        .getProperty(UPnPDevice.UDN), "resource", "icon"))
262                    .toASCIIString());
263            }
264            return result;
265        } finally {
266            context.ungetService(deviceRef);
267        }
268    }
269
270    @SuppressWarnings({ "unchecked", "PMD.AvoidInstantiatingObjectsInLoops" })
271    private List<Map<String, Object>>
272            treeify(Map<String, Map<String, Object>> deviceInfos) {
273        for (Map.Entry<String, Map<String, Object>> e : deviceInfos
274            .entrySet()) {
275            Optional.ofNullable(e.getValue().get("parentUdn")).ifPresent(
276                parentUdn -> ((List<Map<String, Object>>) deviceInfos
277                    .get(parentUdn).computeIfAbsent("childDevices",
278                        k -> new ArrayList<Map<String, Object>>()))
279                            .add(e.getValue()));
280        }
281        return deviceInfos.values().stream()
282            .filter(deviceInfo -> !deviceInfo.containsKey("parentUdn"))
283            .collect(Collectors.toList());
284    }
285
286    @Override
287    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis" })
288    protected void doGetResource(ConletResourceRequest event,
289            IOSubchannel channel) {
290        Map<String, List<String>> query
291            = WebConsoleUtils.queryAsMap(event.resourceUri());
292        if (!query.containsKey("udn")) {
293            super.doGetResource(event, channel);
294            return;
295        }
296        try {
297            Arrays.stream(context.getAllServiceReferences(null,
298                String.format("(&(%s=%s)(%s=%s))", Constants.OBJECTCLASS,
299                    UPnPDevice.class.getName(), UPnPDevice.UDN,
300                    query.get("udn").get(0))))
301                .findFirst().ifPresent(deviceRef -> {
302                    @SuppressWarnings("unchecked")
303                    UPnPDevice device = context
304                        .getService((ServiceReference<UPnPDevice>) deviceRef);
305                    if (query.getOrDefault("resource", Collections.emptyList())
306                        .contains("icon")) {
307                        provideIcon(event, device);
308                    }
309                });
310        } catch (InvalidSyntaxException e) {
311            throw new IllegalArgumentException(e);
312        }
313    }
314
315    @SuppressWarnings({ "PMD.EmptyCatchBlock", "PMD.DataflowAnomalyAnalysis" })
316    private void provideIcon(ConletResourceRequest event, UPnPDevice device) {
317        UPnPIcon[] icons = device
318            .getIcons(event.session().locale().toLanguageTag());
319        if (icons == null) {
320            icons = device.getIcons(null);
321        }
322        if (icons == null) {
323            return;
324        }
325        Arrays.sort(icons,
326            Comparator.comparingInt(UPnPIcon::getHeight).reversed());
327        UPnPIcon icon = icons[0];
328        try {
329            if (event.ifModifiedSince().isPresent()) {
330                event.setResult(new ResourceNotModified(event, Instant.now(),
331                    365 * 24 * 3600));
332            } else {
333                MediaType mediaType = null;
334                if (icon.getMimeType() != null
335                    && !icon.getMimeType().isEmpty()) {
336                    mediaType
337                        = Converters.MEDIA_TYPE
338                            .fromFieldValue(icon.getMimeType());
339                }
340                event.setResult(new ResourceByInputStream(event,
341                    icon.getInputStream(), mediaType, Instant.now(),
342                    365 * 24 * 3600));
343            }
344            event.stop();
345        } catch (IOException | java.text.ParseException e) {
346            // Handle as if no match
347        }
348    }
349
350    /**
351     * Translates the OSGi {@link ServiceEvent} to a JGrapes event and fires it
352     * on all known console session channels.
353     *
354     * @param event the event
355     */
356    @Override
357    public void serviceChanged(ServiceEvent event) {
358        // TODO
359    }
360
361    /**
362     * The conlet's model.
363     */
364    @SuppressWarnings("serial")
365    public class UPnPBrowserModel extends AbstractConlet.ConletBaseModel {
366
367        /**
368         * Instantiates a new service list model.
369         *
370         * @param conletId the conlet id
371         */
372        public UPnPBrowserModel(String conletId) {
373            super(conletId);
374        }
375
376    }
377}