001    /*
002    Copyright (c) 1996-2012, Damon Hart-Davis
003    All rights reserved.
004    
005    Redistribution and use in source and binary forms, with or without
006    modification, are permitted provided that the following conditions are
007    met:
008    
009      * Redistributions of source code must retain the above copyright
010        notice, this list of conditions and the following disclaimer.
011    
012      * Redistributions in binary form must reproduce the above copyright
013        notice, this list of conditions and the following disclaimer in the
014        documentation and/or other materials provided with the
015        distribution.
016    
017    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
018    IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
019    TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
020    PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
021    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
022    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
023    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
024    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
025    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
026    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
027    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
028    */
029    
030    package org.hd.d.pg2k.clApp.uploader;
031    
032    import java.io.BufferedInputStream;
033    import java.io.ByteArrayOutputStream;
034    import java.io.IOException;
035    import java.io.InputStream;
036    import java.io.InterruptedIOException;
037    import java.io.ObjectInputStream;
038    import java.io.ObjectOutputStream;
039    import java.io.OutputStream;
040    import java.net.HttpURLConnection;
041    import java.net.URL;
042    import java.net.URLConnection;
043    import java.util.ArrayList;
044    import java.util.Arrays;
045    import java.util.Collections;
046    import java.util.Comparator;
047    import java.util.Date;
048    import java.util.HashSet;
049    import java.util.List;
050    import java.util.Set;
051    import java.util.TimerTask;
052    import java.util.concurrent.atomic.AtomicLong;
053    import java.util.zip.GZIPInputStream;
054    import java.util.zip.GZIPOutputStream;
055    
056    import javax.jnlp.BasicService;
057    import javax.jnlp.ExtendedService;
058    import javax.jnlp.FileContents;
059    import javax.jnlp.FileOpenService;
060    import javax.jnlp.PersistenceService;
061    import javax.jnlp.ServiceManager;
062    import javax.jnlp.SingleInstanceService;
063    import javax.jnlp.UnavailableServiceException;
064    
065    import org.hd.d.pg2k.svrCore.AllExhibitImmutableData;
066    import org.hd.d.pg2k.svrCore.AllExhibitProperties;
067    import org.hd.d.pg2k.svrCore.CoreConsts;
068    import org.hd.d.pg2k.svrCore.ExhibitName;
069    import org.hd.d.pg2k.svrCore.ExhibitPropsComputable;
070    import org.hd.d.pg2k.svrCore.ExhibitPropsGlobalImmutable;
071    import org.hd.d.pg2k.svrCore.ExhibitPropsLoadable;
072    import org.hd.d.pg2k.svrCore.ExhibitStaticAttr;
073    import org.hd.d.pg2k.svrCore.FileTools;
074    import org.hd.d.pg2k.svrCore.Name;
075    import org.hd.d.pg2k.svrCore.ROByteArray;
076    import org.hd.d.pg2k.svrCore.Rnd;
077    import org.hd.d.pg2k.svrCore.SimpleLoggerIF;
078    import org.hd.d.pg2k.svrCore.MIME.ExhibitMIME;
079    import org.hd.d.pg2k.svrCore.datasource.ExhibitDataHTTPTunnelSource;
080    import org.hd.d.pg2k.svrCore.datasource.ExhibitDataTunnelSource;
081    import org.hd.d.pg2k.svrCore.props.GenProps;
082    import org.hd.d.pg2k.svrCore.uploader.UploadInfoBean;
083    import org.hd.d.pg2k.svrCore.uploader.UploaderConsts;
084    import org.hd.d.pg2k.svrCore.uploader.UploaderUtils;
085    
086    import ORG.hd.d.IsDebug;
087    
088    
089    /**Uploader "business-logic" holder.
090     * Is designed to be GUI-free and just contain the logic.
091     * <p>
092     * Package visible since need be seen only by the main GUI class.
093     */
094    final class UploaderLogic
095        {
096        /**Package-visible constructor since need be seen only by the main GUI class.
097         * @param logger  reference to central logger; never null
098         */
099        UploaderLogic(final SimpleLoggerIF logger)
100            {
101            assert(logger != null);
102            this.logger = logger;
103    
104            // Get access to basic services.
105            BasicService basicService = null;
106            try { basicService = (BasicService) ServiceManager.lookup("javax.jnlp.BasicService"); }
107            catch(final UnavailableServiceException e) { }
108            bs = basicService;
109    
110            // Get access to persistence management.
111            PersistenceService persistenceService = null;
112            try { persistenceService = (PersistenceService) ServiceManager.lookup("javax.jnlp.PersistenceService"); }
113            catch(final UnavailableServiceException e) { }
114            ps = persistenceService;
115    
116            // Get access to file open services.
117            FileOpenService fileOpenService = null;
118            try { fileOpenService = (FileOpenService) ServiceManager.lookup("javax.jnlp.FileOpenService"); }
119            catch(final UnavailableServiceException e) { }
120            fos = fileOpenService;
121    
122            // Get access to single-instance services.
123            SingleInstanceService singleInstanceService = null;
124            try { singleInstanceService = (SingleInstanceService) ServiceManager.lookup("javax.jnlp.SingleInstanceService"); }
125            catch(final UnavailableServiceException e) { }
126            sis = singleInstanceService;
127    
128            // Get access to extended services.
129            ExtendedService extendedService = null;
130            try { extendedService = (ExtendedService) ServiceManager.lookup("javax.jnlp.ExtendedService"); }
131            catch(final UnavailableServiceException e) { }
132            exs = extendedService;
133    
134            if((ps != null) && (bs != null))
135                {
136                // Try to reload the properties/preferences...
137                // This should be quick,
138                // and doing it here should avoid races with the UI.
139                try
140                    {
141                    final FileContents fc = ps.get(makePropsURL(bs));
142                    final InputStream inputStream = fc.getInputStream();
143                    try { props.loadFromPersistentData(inputStream); }
144                    finally { inputStream.close(); }
145                    }
146                catch(final IOException e)
147                    {
148                    logger.log("No saved properties/preferences to reload (yet).");
149                    }
150                }
151            else
152                {
153                // If there is no persistence,
154                // then create a default AEP,
155                // for testing, and to get users started.
156                final Set<ExhibitStaticAttr> esas = new HashSet<ExhibitStaticAttr>();
157                esas.add(new ExhibitStaticAttr(DEFAULT_EXHIBIT_NAME, 1, System.currentTimeMillis()));
158                final AllExhibitProperties defaultAEP =
159                    new AllExhibitProperties(new ExhibitPropsGlobalImmutable(),
160                                             new AllExhibitImmutableData(esas, 1),
161                                             Collections.<Name.ExhibitFull, ExhibitPropsLoadable>emptyMap(),
162                                             Collections.<Name.ExhibitFull, ExhibitPropsComputable>emptyMap());
163    
164                aep = defaultAEP;
165                logger.log("Created default AEP.");
166    
167                // Set ID to match for now.
168                props.setUserID(ExhibitName.getAuthorComponent(DEFAULT_EXHIBIT_NAME).toString());
169                }
170    
171            // Create our tunnel endpoint if at least basic service is available.
172            // Create the tunnel before any async threads are running.
173            URL masterURL = null;
174            if(bs != null)
175                {
176                try { masterURL = new URL(bs.getCodeBase(), CoreConsts.TUNNEL_URI); }
177                catch(final Exception e) { }
178                }
179            tunnel = ((bs == null) || (masterURL == null)) ? null :
180                new ExhibitDataHTTPTunnelSource(masterURL.toString(), "", logger);
181    
182            // Create our worker threads for uploads.
183            // They get start()ed after the constructor has completed, by startup().
184            final List<Thread> workers = new ArrayList<Thread>(MAX_CONC_UPLOADS);
185            for(int i = 0; i < MAX_CONC_UPLOADS; ++i)
186                { workers.add(new UploaderWorkerThread(i)); }
187            // Store immutable List view of unstarted uploader threads.
188            uploaderWorkerThreads = Collections.unmodifiableList(workers);
189            }
190    
191        /**Reference to central logger; never null.
192         * We make this package-visible,
193         * though the preferred access is via the Main class.
194         */
195        final SimpleLoggerIF logger;
196    
197        /**Persistence properties; never null.
198         * Package-visible so as to be directly usable by GUI classes.
199         */
200        final UploaderProps props = new UploaderProps();
201    
202    
203        /**Handle on JWS basic service; null if none.
204         * Package-visible so as to be directly usable by GUI classes.
205         */
206        final BasicService bs;
207    
208        /**Handle on JWS persistence service; null if none.
209         * Package-visible so as to be directly usable by GUI classes.
210         */
211        final PersistenceService ps;
212    
213        /**Handle on JWS file-open service; null if none.
214         * Package-visible so as to be directly usable by GUI classes.
215         */
216        final FileOpenService fos;
217    
218        /**Handle on JWS singleton service; null if none.
219         * Package-visible so as to be directly usable by GUI classes.
220         */
221        final SingleInstanceService sis;
222    
223        /**Handle on JWS extended service; null if none.
224         * Package-visible so as to be directly usable by GUI classes.
225         */
226        final ExtendedService exs;
227    
228    
229        /**Our end of main tunnel to server; null if no tunnel possible.
230         * Package-visible so as to be directly usable by GUI classes.
231         */
232        final ExhibitDataHTTPTunnelSource tunnel;
233    
234    
235        /**Uploader worker thread.
236         * Private to this class.
237         */
238        private final class UploaderWorkerThread extends Thread
239            {
240            UploaderWorkerThread(final int index)
241                {
242                super("upload worker thread " + index);
243    
244                if((index < 0) || (index > MAX_CONC_UPLOADS))
245                    { throw new IllegalArgumentException(); }
246    
247                setDaemon(true); // Daemon by default.
248    
249                this.index = index;
250                }
251    
252            /**Index of this worker thread. */
253            final int index;
254    
255            /**Time an idle worker sleeps for in ms; strictly positive. */
256            private static final int WORKER_SLEEP_MS = 1011;
257    
258    
259            /**Do any uploading; sleep when nothing to do.
260             */
261            @Override
262            public void run()
263                {
264                // Run forever...
265                for( ; ; )
266                    {
267                    // At the start of each loop our slot must be empty.
268                    assert(inProgressSlots[index] == null);
269    
270                    try
271                        {
272                        // Short-cut when no work to be done...
273                        // If nothing at all to upload
274                        // or no basic service
275                        // or invalid user credentials
276                        // or broken connection to the server
277                        // then sleep awhile...
278                        if((uploadingFiles.size() == 0) ||
279                           (bs == null) ||
280                           (!ExhibitName.validAuthorSyntax(userID)) ||
281                           (tunnel.isBroken()))
282                            {
283                            // Sleep then go back and check again.
284                            // Use a randomised time to help avoid collisions.
285                            Thread.sleep(WORKER_SLEEP_MS + Rnd.fastRnd.nextInt(WORKER_SLEEP_MS));
286                            continue;
287                            }
288    
289                        // Find the first available item in the upload queue
290                        // with no error,
291                        // and not in any other upload slot (ie not being uploaded).
292                        //
293                        // Use a lock on inProgressSlots[]
294                        // to avoid selection races with more than one worker
295                        // starting to upload the same file.
296                        //
297                        // Grab the uploadingFiles lock inside it.
298                        AtomicLong bytesUploaded = null;
299                        UploadStatus stats = null;
300                        SelectedFileDetails selectedFileDetails = null;
301                        synchronized(inProgressSlots)
302                            {
303                            synchronized(uploadingFiles)
304                                {
305                                selectLoop: for(int i = uploadingFiles.size(); --i >= 0; )
306                                    {
307                                    selectedFileDetails = uploadingFiles.get(i);
308    
309                                    // This file is not a candidate
310                                    // if it has any error or other non-null status.
311                                    if(selectedFileDetails.getStatus(UploaderLogic.this) != null)
312                                        { continue; }
313    
314                                    // This file is not a candidate
315                                    // if any (other) worker is already uploading it.
316                                    for(int j = MAX_CONC_UPLOADS; --j >= 0; )
317                                        {
318                                        if(selectedFileDetails.equals(inProgressSlots[j]))
319                                            { continue selectLoop; }
320                                        }
321    
322                                    // OK, mark this as uploading
323                                    // and copy it to our slot.
324                                    selectedFileDetails.setStatus("Uploading... (worker "+index+")");
325                                    bytesUploaded = new AtomicLong(-1); // Not started data yet...
326                                    inProgressSlots[index] = stats = new UploadStatus(selectedFileDetails, bytesUploaded);
327                                    break;
328                                    }
329                                }
330                            }
331    
332                        // If we didn't find anything to do, then sleep.
333                        // This might happen in a race for work with other threads.
334                        if(stats == null)
335                            {
336                            // Use a randomised time to help avoid collisions.
337                            Thread.sleep(WORKER_SLEEP_MS + Rnd.fastRnd.nextInt(WORKER_SLEEP_MS));
338                            continue;
339                            }
340    
341                        // We expect all the refs to be filled in (non-null).
342                        assert(selectedFileDetails != null);
343                        assert(bytesUploaded != null);
344    
345                        // Note time before starting this upload.
346                        final long startTime = System.currentTimeMillis();
347    
348                        URLConnection uc = null; // openURLConnection;
349                        HttpURLConnection huc = null;
350                        try
351                            {
352                            // Compute path at which batch-upload JSP is mounted...
353                            final URL u = (new URL(bs.getCodeBase(), "/_upload/batchupload"));
354                            uc = u.openConnection();
355    
356                            if(!(uc instanceof HttpURLConnection))
357                                { throw new IOException("incorrect connection type from URL: " + u); }
358                            huc = (HttpURLConnection) uc;
359    
360                            // Set properties of connection.
361                            uc.setDoInput(true);
362                            uc.setDoOutput(true);
363                            uc.setUseCaches(false);
364                            // Wait only a short while for a connection.
365                            // If we can't even get a connection quickly
366                            // then we might well have problems with uploads anyway.
367                            uc.setConnectTimeout(2001 + (UploaderConsts.BATCH_UPLOAD_MAX_IDLE_MS / 2));
368                            // Make read timeout MUCH longer than others
369                            // so as to try hard not to lose uploads in progress.
370                            uc.setReadTimeout(12001 + (UploaderConsts.BATCH_UPLOAD_MAX_IDLE_MS * 2));
371                            // Don't bother the user for any interaction.
372                            uc.setAllowUserInteraction(false);
373    
374                            // Set auth credentials...
375                            uc.setRequestProperty("X-" + UploaderConsts.USERID_FIELD_NAME, String.valueOf(userID));
376                            uc.setRequestProperty("X-" + UploaderConsts.PASSWD_FIELD_NAME, String.valueOf(passwd));
377    
378                            // We know what the content length will be
379                            // so set it here to allow some HTTP optimisations.
380                            final int contentLength = UploaderUtils.computeLengthOfRequestFromBatchUploadClient(
381                                selectedFileDetails.getEsa(),
382                                selectedFileDetails.getHashMD5(),
383                                selectedFileDetails.getDescription());
384                            huc.setRequestProperty("Content-Length", String.valueOf(contentLength));
385                            huc.setFixedLengthStreamingMode(contentLength);
386    
387                            // This must be a POST request.
388                            huc.setRequestMethod("POST");
389    
390                            // No redirects should be needed.
391                            huc.setInstanceFollowRedirects(false);
392    
393                            // Force connection now...
394                            uc.connect();
395                            }
396                        catch(final Exception e)
397                            {
398                            e.printStackTrace();
399                            logger.log("CANNOT CONNECT TO SERVER: " + e.getMessage());
400    
401                            // Check for a 403 (forbidden) error code.
402                            if(huc != null)
403                                {
404                                if(huc.getResponseCode() == 403)
405                                    {
406                                    logger.log("CANNOT CONNECT TO SERVER: uploads disabled, or user name or password wrong?: " + e.getMessage());
407                                    }
408                                }
409    
410                            // Whinge but don't penalise the file itself
411                            // if we could not even get a server connection.
412                            inProgressSlots[index] = null;
413                            selectedFileDetails.setStatus(null); // Not the file's problem...
414                            // Use a randomised time to help avoid collisions.
415                            // A longer-than-usual time for the server to recover.
416                            Thread.sleep(3 * WORKER_SLEEP_MS + Rnd.fastRnd.nextInt(5 * WORKER_SLEEP_MS));
417                            continue;
418                            }
419    
420                        IOException writeIOException = null;
421    
422                        // Send request and data up the wire...
423                        final OutputStream os = uc.getOutputStream();
424                        try
425                            {
426                            final InputStream exhibitDataStream =
427                                new BufferedInputStream(selectedFileDetails.getFc().getInputStream());
428                            try
429                                {
430                                // Not within any locks
431                                // (to allow good concurrency),
432                                // upload the new exhibit...
433                                UploaderUtils.sendRequestFromBatchUploadClient(
434                                    os,
435                                    selectedFileDetails.getEsa(),
436                                    selectedFileDetails.getHashMD5(),
437                                    selectedFileDetails.getDescription(),
438                                    exhibitDataStream,
439                                    bytesUploaded);
440                                os.flush(); // Force all data upstream.
441                                }
442                            finally { exhibitDataStream.close(); /* Release resources. */ }
443                            }
444                        catch(final IOException e)
445                            {
446                            // Note error in upload...
447                            selectedFileDetails.setStatus("ERROR: " + e.getMessage());
448                            writeIOException = e;
449                            }
450                        finally { os.close(); }
451    
452                        final InputStream is = uc.getInputStream();
453                        try
454                            {
455                            // Collect OK/FAIL response from server...
456                            if(!selectedFileDetails.getEsa().getFilePath().equals(UploaderUtils.decodeResponseToBatchUploadClient(is)))
457                                { throw new IOException("got invalid response to upload from server"); }
458                            }
459                        finally { is.close(); }
460    
461                        // If we got an exception sending then rethrow it now.
462                        if(writeIOException != null) { throw writeIOException; }
463    
464                        // If OK then remove the file entirely
465                        // (though record the hash to avoid uploading a dup later).
466                        selectedFileDetails.setStatus(null); // Done!
467                        uploadedHashesMD5.add(selectedFileDetails.getHashMD5()); // Avoid dup upload.
468                        uploadingFiles.remove(selectedFileDetails); // Uploaded!
469    
470                        // Clear slot...
471                        inProgressSlots[index] = null;
472    
473                        // Record finish time including most of the overheads
474                        // to give a conservative Bps throughput measure.
475                        final long endTime = System.currentTimeMillis();
476                        final long durationMS = endTime - startTime;
477    
478                        final long length = selectedFileDetails.getEsa().length;
479                        final long Bps = (1000*length + 500) / Math.max(1, durationMS);
480                        updateSmoothedUploadSpeedBps(Bps);
481    
482                        logger.log("Finished uploading exhibit #"+getCompletedUploadsThisSession()+" at "+Bps+"Bps:  "+length+" bytes in "+durationMS+"ms.");
483    
484                        // If no files left to upload then we could disconnect...
485                        if((uploadingFiles.size() == 0) && (huc != null))
486                            { huc.disconnect(); }
487                        }
488                    catch(final Throwable e)
489                        {
490                        // Log the problem.
491                        e.printStackTrace();
492                        logger.log("UPLOAD ERROR: " + e.getMessage());
493    
494                        // Mark any in-progress item as in error.
495                        final UploadStatus inProgress = inProgressSlots[index];
496                        if(inProgress != null)
497                            { inProgress.file.setStatus("ERROR: " + e.getMessage()); }
498    
499                        // Kill the content of our slot.
500                        inProgressSlots[index] = null;
501    
502                        // Sleep a bit in case the error condition keeps occurring.
503                        try { Thread.sleep(2*WORKER_SLEEP_MS + Rnd.fastRnd.nextInt(WORKER_SLEEP_MS)); }
504                        catch(final InterruptedException ignored) { }
505                        }
506                    }
507                }
508            }
509    
510    
511        /**Time-constant for updating smoothedUploadSpeedBps; strictly positive.
512         * The larger this is, the more smoothed is the computed value.
513         * <p>
514         * A value of 1 gives no smoothing at all.
515         * <p>
516         * A power of two may result in very slightly faster computations.
517         * <p>
518         * A value in the range 2 to 64 is probably good.
519         */
520        private static final int TC_SUSB = 16;
521    
522        /**Current smoothed upload performance in bytes-per-second, initially -1; never null.
523         * Initially -1 before any exhibit is uploaded.
524         * Thereafter updated via a smoothing (low-pass-filter) algorithm
525         * to reflect recent history.
526         * <p>
527         * This represents a conservative performance measure,
528         * ie including overheads such as connection set-up time,
529         * but not dead time between uploads.
530         * <p>
531         * AtomicLong allows thread-safe lockless access for read and write.
532         */
533        private final AtomicLong smoothedUploadSpeedBps = new AtomicLong(-1);
534    
535        /**Get current smoothed upload performance in bytes-per-second, initially -1.
536         * Initially -1 before any exhibit is uploaded.
537         * Thereafter updated via a smoothing (low-pass-filter) algorithm
538         * to reflect recent history.
539         * <p>
540         * This represents a conservative performance measure,
541         * ie including overheads such as connection set-up time,
542         * but not dead time between uploads.
543         * <p>
544         * This is thread-safe and lock-free.
545         * <p>
546         * Package-visible to allow access by GUI classes.
547         */
548        final long getSmoothedUploadSpeedBps()
549            { return(smoothedUploadSpeedBps.get()); }
550    
551        /**Update our notion of mean upload speed (in bytes per second).
552         * The argument must be non-negative,
553         * and should be conservative,
554         * ie including overheads such as connection set-up time,
555         * but not dead time between uploads.
556         * <p>
557         * This will be used to update a smoothed performance measure.
558         * <p>
559         * This tries to allow for concurrent uploads,
560         * ie where the total throughput is shared by several workers.
561         * <p>
562         * This is thread-safe and lock-free.
563         *
564         * @param Bps  non-negative speed of upload in bytes per second
565         */
566        private void updateSmoothedUploadSpeedBps(long Bps)
567            {
568            if(Bps < 0) { throw new IllegalArgumentException(); }
569    
570            // We may adjust Bps to allow for multiple concurrent workers.
571            final int filesStillQueuedToUpload = uploadingFiles.size();
572            if((MAX_CONC_UPLOADS > 1) && (filesStillQueuedToUpload > 0))
573                {
574                // We probably just finished uploading a file,
575                // and were probably uploading up to the maximum concurrency
576                // that that previous queue length implies.
577                Bps *= Math.max(1, Math.min(MAX_CONC_UPLOADS, filesStillQueuedToUpload+1));
578                }
579    
580            // Retry until we manage to set the value...
581            for( ; ; )
582                {
583                final long current = smoothedUploadSpeedBps.get();
584                // Compute a smoothed updated value,
585                // though in the first instance (value is -1)
586                // set the value to the one passed in with no smoothing.
587                final long updated = (current < 0) ? Bps :
588                                        ((current + (Bps * (TC_SUSB-1))) / TC_SUSB);
589                if(smoothedUploadSpeedBps.compareAndSet(current, updated))
590                    {
591                    return; /* Update succeeded, so return! */
592                    }
593                }
594            }
595    
596    
597        /**Set of MD5 hashes of exhibits already uploaded (or rejected as duplicate content); never null.
598         * This information currently does not persist from one run to the next,
599         * so can be cleared by restarting the app.
600         * <p>
601         * Private so as to prevent accidental deletion of entries!
602         * <p>
603         * Thread-safe.
604         */
605        private final Set<ROByteArray> uploadedHashesMD5 =
606            Collections.synchronizedSet(new HashSet<ROByteArray>());
607    
608        /**Check (using the content MD5 hash) if a putative exhibit has already been uploaded (or rejected as duplicate content).
609         * Package-visible so as to be directly usable by GUI classes.
610         */
611        boolean checkIfAlreadyUploadedByHashMD5(final ROByteArray hashMD5)
612            {
613            if((hashMD5 == null) || (hashMD5.length() != 16))
614                { throw new IllegalArgumentException(); }
615            return(uploadedHashesMD5.contains(hashMD5));
616            }
617    
618        /**Get number of exhibits successfully uploaded this session; non-negative. */
619        int getCompletedUploadsThisSession()
620            { return(uploadedHashesMD5.size()); }
621    
622    
623        /**Details of one selected file being uploaded; immutable except for progress value. */
624        static final class UploadStatus
625            {
626            /**Selected file; never null. */
627            final SelectedFileDetails file;
628    
629            /**Current upload progress (bytes uploaded); never null.
630             * Private so that only get/read access is possible.
631             */
632            private final AtomicLong progress;
633    
634            /**Get bytes of this exhibit uploaded so far.
635             * Thread-safe.
636             *
637             * @return -1 if upload of data not yet started,
638             *     Long.MAX_VALUE once all data has been sent,
639             *     else the number of bytes sent/uploaded
640             */
641            long getBytesUploaded() { return(progress.get()); }
642    
643            /**Create an instance.
644             * All args must be non-null and unique to this instance.
645             */
646            UploadStatus(final SelectedFileDetails selectedFile,
647                         final AtomicLong bytesUploaded)
648                {
649                if((selectedFile == null) || (bytesUploaded == null))
650                    { throw new IllegalArgumentException(); }
651                file = selectedFile;
652                progress = bytesUploaded;
653                }
654            }
655    
656    
657        /**Maximum simultaneous uploads (to overcome network latency, etc); strictly positive.
658         * A value of much higher than 2 is more likely to cause network congestion
659         * than actually fix anything, so a value of 1 or 2 is probably optimal.
660         */
661        static final int MAX_CONC_UPLOADS = 2;
662    
663        /**Our (daemon) uploader worker threads started by startup(); never null. */
664        private final List<Thread> uploaderWorkerThreads;
665    
666        /**Our current uploads-in-progress; never null and always MAX_CONC_UPLOADS long.
667         * Each uploader worker thread controls the content of the slot/index
668         * indicated by its own index.
669         * <p>
670         * A slot is empty when not in use; all slots are initially empty.
671         * <p>
672         * Private so as to prevent accidental deletion of entries!
673         * <p>
674         * All access to the contents of this array should grab a lock
675         * on this array to ensure that an up-to-date/valid view is seen.
676         * <p>
677         * Worker threads keep a lock on this while looking for a
678         * file to start uploading,
679         * and thus the uploadingFiles instance lock may be grabbed inside this one;
680         * the reverse is not allowed so as to avoid deadlocks.
681         */
682        private final UploadStatus inProgressSlots[] = new UploadStatus[MAX_CONC_UPLOADS];
683    
684        /**Get the current contents of an in-progress slot; may return null.
685         * Package-visible so as to be directly usable by GUI classes.
686         */
687        UploadStatus getInProgressSlot(final int index)
688            { synchronized(inProgressSlots) { return(inProgressSlots[index]); } }
689    
690    
691        /**Name under the codebase where we persist the properties/preferences. */
692        private static final String FNAME_MAIN_PROPS = "main.properties";
693    
694        /**Name under the codebase where we persist the AEP. */
695        private static final String FNAME_AEP = "aep.ser.gz";
696    
697    
698        /**Database of selected files; never null.
699         * Package-visible for direct manipulation by GUI classes.
700         * <p>
701         * Thread-safe.
702         */
703        final SelectedFilesDB selectedFiles = new SelectedFilesDB();
704    
705        /**Database of files queued for upload; never null.
706         * Package-visible for direct manipulation by GUI classes.
707         * <p>
708         * Thread-safe.
709         */
710        final SelectedFilesDB uploadingFiles = new SelectedFilesDB();
711    
712    
713        /**Last time the user was active in this run, eg in the UI; initially zero.
714         * Volatile to allow access without a lock.
715         */
716        private volatile long userLastActive;
717    
718        /**Get the last time the user was active in this run, eg in the UI; initially zero.
719         * No harm in letting everyone see this, so is public.
720         * <p>
721         * When the user has not been active for a long time,
722         * some activities, such as polling the server, may halt or slow down
723         * to conserve resources.
724         */
725        public long getUserLastActive() { return(userLastActive); }
726    
727        /**Note user as active.
728         * Made package-visible (ie not public) for security.
729         * <p>
730         * This may be triggered/called by a number of events
731         * that indicate that the user is active.
732         * <p>
733         * When the user has not been active for a long time,
734         * some activities, such as polling the server, may halt or slow down
735         * to conserve resources.
736         */
737        void setUserLastActive(final String doingWhat)
738            {
739            userLastActive = System.currentTimeMillis();
740    //logger.log("UI active: " + doingWhat + ": " + (new Date())); }
741            }
742    
743        /**Default exhibit name when there is an empty AEP. */
744        final String DEFAULT_EXHIBIT_NAME = "places-and-sights/example-ANON.jpg";
745    
746        /**Load any (large) persisted data from a previous execution and start a background worker thread.
747         * Do this as early as possible after construction,
748         * and preferably before allowing (much) user interaction.
749         * <p>
750         * We spin this off into a separate thread to avoid blocking startup.
751         * <p>
752         * This should be called at most once.
753         * <p>
754         * Package-visible so as to be directly usable by GUI classes.
755         */
756        void startup()
757            {
758            final Thread th = new Thread("UploaderLogic startup()"){
759                /**Do load work as background thread. */
760                @Override
761                public final void run()
762                    {
763                    if((ps != null) && (bs != null))
764                        {
765                        // Try to reload the AEP...
766                        // This may take a while.
767                        logger.log("Reloading saved AEP...");
768                        try
769                            {
770                            final FileContents fc = ps.get(makeAEPURL(bs));
771                            final ObjectInputStream ois = new ObjectInputStream(new GZIPInputStream(fc.getInputStream()));
772                            try
773                                {
774                                final AllExhibitProperties allExhibitProperties = (AllExhibitProperties) ois.readObject();
775                                if(null != allExhibitProperties)
776                                    {
777                                    aep = allExhibitProperties;
778                                    logger.log("Reloaded saved AEP.");
779                                    }
780                                }
781                            finally { ois.close(); }
782                            }
783                        catch(final Exception e)
784                            {
785                            logger.log("No valid saved AEP to reload.");
786                            }
787                        }
788    
789                    // Once we have finished trying to load any saved AEP,
790                    // start a (daemon) poller thread for non-UI async activity
791                    // such as fetching any updated AEP over the tunnel.
792                    // After a short delay, run approximately every second or so.
793                    // This is created and started AFTER the UI object construction
794                    // is complete to avoid any unpleasant races.
795                    final java.util.Timer timer = new java.util.Timer(true);
796                    timer.schedule(new TimerTask(){
797                        /**The action to be performed by this timerUI task. */
798                        @Override
799                        public final void run() { poll(); }
800                        }, 3000, 2000 + Rnd.fastRnd.nextInt(1000));
801    
802                    // Finally start our uploader worker threads...
803                    for(final Thread t : uploaderWorkerThreads)
804                        { t.start(); }
805                    }
806                };
807            th.start();
808            }
809    
810        /**Make the props persistence URL; only viable when in JWS/JNLP. */
811        private static URL makePropsURL(final BasicService bs) throws IOException
812            {
813            final URL codebase = bs.getCodeBase();
814            final URL urlProps = new URL(codebase, FNAME_MAIN_PROPS);
815            return(urlProps);
816            }
817    
818        /**Make the AEP persistence URL; only viable when in JWS/JNLP. */
819        private static URL makeAEPURL(final BasicService bs) throws IOException
820            {
821            final URL codebase = bs.getCodeBase();
822            final URL urlAEP = new URL(codebase, FNAME_AEP);
823            return(urlAEP);
824            }
825    
826        /**Our private copy of the (less sensitive) valid user ID, or null if none or invalid ID/pass presented. */
827        private volatile String userID;
828    
829        /**Our private note of the password supplied. */
830        private volatile String passwd;
831    
832        /**Set whenever the userID changes; cleared when we have attempted to test the connection.
833         * This is <em>not</em> critical, and may suffer races.
834         * <p>
835         * This is simply a device to try to get a new user ID/passwd tested
836         * quietly in the background.
837         */
838        private volatile boolean userIDChanged;
839    
840        /**Set authentication information for this user.
841         * To set authentication arguments must be non-null and non-empty,
842         * else all arguments must be null to clear authentication data.
843         * <p>
844         * Package-visible so as to be directly usable by the main GUI class.
845         * <p>
846         * Ignored if there is no tunnel.
847         */
848        void setAuthenticationInfo(final String userID, final char[] passwd)
849            {
850            if(tunnel == null)
851                { return; }
852    
853            // Any null/empty argument implies unsetting the authentication data.
854            if((userID == null) || (userID.length() == 0) ||
855               (passwd == null) || (passwd.length == 0))
856                {
857                this.userID = null;
858                this.passwd = null;
859                tunnel.setAuthenticationInfo(null, null);
860                return;
861                }
862    
863            // If the arguments passed are syntactically invalid,
864            // then ignore them and clear any extant authentication data instead.
865            if(!ExhibitName.validAuthorSyntax(userID) ||
866               (passwd.length < CoreConsts.MIN_PASSWORD_LEN) ||
867               (passwd.length > CoreConsts.MAX_PASSWORD_LEN))
868                {
869                this.userID = null;
870                this.passwd = null;
871                tunnel.setAuthenticationInfo(null, null);
872                return;
873                }
874    
875            // The authentication data provided is syntactically OK.
876            // Set it for the tunnel.
877            // Also make it available for the upload worker threads.
878            final String s = new String(passwd);
879            final boolean changed =
880                !userID.equals(this.userID) ||
881                !this.passwd.equals(s);
882            this.userID = userID;
883            this.passwd = s;
884            tunnel.setAuthenticationInfo(userID, passwd);
885    
886            if(changed)
887                {
888                // We take an ID change to be indicative of user activity.
889                setUserLastActive("user ID changed");
890    
891                // Set this flag *after* setting the value on the tunnel, etc.
892                userIDChanged = true;
893                }
894            }
895    
896        /**Exhibit properties; never null.
897         * Set by poll().
898         * <p>
899         * Volatile so as to be safe to access without a lock if need be.
900         */
901        private volatile AllExhibitProperties aep = new AllExhibitProperties();
902    
903        /**Get the current AEP (exhibit properties); never null. */
904        AllExhibitProperties getAep() { return(aep); }
905    
906        /**Generic properties; never null.
907         * Set and used by poll().
908         * <p>
909         * Volatile so as to be safe to access without a lock if need be.
910         */
911        private volatile GenProps gp = new GenProps();
912    
913        /**Get the current set of GenProps (generic system properties); never null. */
914        GenProps getGenProps() { return(gp); }
915    
916        /**Note of polled connection state; TRUE is good, FALSE is bad, null is unknown.
917         * Set and used by poll().
918         * <p>
919         * Volatile so as to be safe to access without a lock if need be.
920         */
921        private volatile Boolean connectionGood;
922    
923        /**Time we last polled for GP/AEP updates.
924         * Set and used by poll() and used to put off another poll,
925         * so should always be set after a successful poll,
926         * but may also be set after some unsuccessful polls to reduce traffic to an unhappy master.
927         * <p>
928         * Volatile so as to be safe to access without a lock if need be.
929         */
930        private volatile long lastAEPUpdatePoll;
931    
932        /**Maximum interval between polls for new values of AEP (and GP); strictly positive.
933         * Relatively short for this interactive application.
934         * <p>
935         * Short enough to capture an AEP generated asynchronously for us
936         * at the server side of the HTTP tunnel.
937         * <p>
938         * We may suspend polling entirely if the UI/app appears to be idle.
939         */
940        private static final int MAX_AEP_POLL_MS = Math.min(10 * 60 * 1000, ExhibitDataTunnelSource.MIN_AEP_RETENTION_MS/2);
941    
942        /**Called (by a daemon thread) to perform async activity.
943         * This is not Swing-safe, and does not block Swing if blocked.
944         * <p>
945         * This is not called until construction is complete.
946         * <p>
947         * Package-visible so as to be directly usable by the main GUI class.
948         */
949        void poll()
950            {
951            try
952                {
953                // Perform tunnel processing, if any.
954                final ExhibitDataHTTPTunnelSource t = tunnel;
955                if((t != null) && (userID != null))
956                    {
957                    try
958                        {
959                        // Perform background activity for the tunnel, if extant.
960                        t.poll(gp);
961    
962                        // Try a new/updated user/password combo in the background.
963                        // Force recheck if state is not known to be good.
964                        if((userID != null) &&
965                           (userIDChanged || !Boolean.TRUE.equals(connectionGood)))
966                            {
967                            // Don't try again unless ID/passwd is changed again...
968                            userIDChanged = false;
969    
970                            try
971                                {
972                                // Try a real connection if we're not seeing errors.
973                                t.doNOOP(false);
974    
975                                // Poll was successful.
976                                connectionGood = Boolean.TRUE;
977                                logger.log("Connection to server established.");
978                                }
979                            catch(final IOException e)
980                                {
981                                connectionGood = Boolean.FALSE;
982                                logger.log("Connection to server bad/unauthorised: " + e.getMessage());
983                                }
984                            }
985    
986                        // If we have a good connection
987                        // and we have no AEP (or GP)
988                        // or it is a while since we last polled for them
989                        // then try to fetch updates/changes to them.
990                        // We use the same short-ish refetch interval
991                        // for both GenProps and AEP
992                        // for this very interactive app.
993                        //
994                        // We don't do this if the user hasn't been active
995                        // since the last poll.
996                        // This conserves resources and enhances security.
997                        if(Boolean.TRUE.equals(connectionGood) &&
998                           (getUserLastActive() > lastAEPUpdatePoll) &&
999                           ((aep.aeid.length == 0) || (gp.timestamp == 0) || // Missing vital data...
1000                                (System.currentTimeMillis() > lastAEPUpdatePoll +
1001                                      Math.min(MAX_AEP_POLL_MS, gp.getWEBSVR_MIN_EX_IMATTR_RECHECK_MS()))))
1002                            {
1003                            logger.log("PLEASE WAIT: fetching generic system properties from server...");
1004                            final GenProps gpNew = t.getGenProps(gp.timestamp);
1005                            if(gpNew != null)
1006                                {
1007                                gp = gpNew;
1008                                logger.log("Updated GenProps as of: " + new Date(gpNew.timestamp) + ", exhibit max size: " + gpNew.getWEBSVR_MAX_EX_BYTES());
1009                                assert(gpNew.timestamp != 0);
1010                                }
1011    
1012                            logger.log("PLEASE WAIT: fetching AllExhibitProperties from server... (may take several of your Earth minutes)");
1013                            final long fetchStart = System.currentTimeMillis();
1014                            final AllExhibitProperties aepNew = t.getAllExhibitProperties(aep, true);
1015                            final long fetchEnd = System.currentTimeMillis();
1016                            logger.log("Finished fetching AEP... ("+((fetchEnd-fetchStart+500)/1000)+"s)");
1017                            lastAEPUpdatePoll = System.currentTimeMillis(); // Postpone next poll...
1018                            if(aepNew != null)
1019                                {
1020                                // Reject all-zeroes AEP as probably master in process of reloading.
1021                                if(aepNew.aeid.length == 0)
1022                                    { throw new IOException("Ignoring empty AEP from server: assumed transient during start-up"); }
1023    
1024                                // Save new AEP *before* we try to cache it,
1025                                // since cacheing may fail due to JNLP restrictions.
1026                                aep = aepNew;
1027    
1028                                // Now attempt to cache the AEP locally.
1029                                logger.log("Cacheing AEP locally...");
1030                                try
1031                                    {
1032                                    cacheAEP(aepNew);
1033                                    logger.log("Cached AEP successfully.");
1034                                    }
1035                                catch(final Exception e)
1036                                    {
1037                                    logger.log("Failed to cache AEP locally: " + e.getMessage());
1038                                    }
1039                                }
1040                            }
1041                        }
1042                    catch(final InterruptedIOException e)
1043                        {
1044                        // The status remains unchanged because
1045                        // InterruptedIOException is an invitation to retry soon.
1046                        logger.log("RPC interrupted, will retry: " + e.getMessage());
1047                        }
1048                    catch(final IOException e)
1049                        {
1050                        // This status is unknown rather than bad,
1051                        // as other things than a bad authorisation
1052                        // such as a temporary server overload
1053                        // may cause an IOException.
1054                        connectionGood = null;
1055                        logger.log("Connection to server status unknown: " + e.getMessage());
1056                        //e.printStackTrace();
1057                        }
1058                    }
1059    
1060                // Save any internal state if it has changed...
1061                final byte[] persistentDataIfChanged = props.getPersistentDataIfChanged();
1062                if(null != persistentDataIfChanged)
1063                    {
1064    if(IsDebug.isDebug) { logger.log("Properties data to save (bytes): " + persistentDataIfChanged.length); }
1065                    saveProperties();
1066                    }
1067                }
1068            catch(final Exception e)
1069                {
1070                // Absorb any exceptions to avoid killing the timer.
1071                logger.log("Unexpected exception: " + e.getMessage());
1072                e.printStackTrace();
1073                }
1074            }
1075    
1076        /**Number of bytes of expansion room we allow for one growth in the AEP; non-negative.
1077         * We can do this so that we do not ask the JNLP/user too often
1078         * for expanded storage space.
1079         * <p>
1080         * Should typically allow for a few days' growth as new exhibits are added.
1081         */
1082        private static final int AEP_EXPANSION_BYTES = 30001;
1083    
1084        /**Save/cache local copy of AEP in persistent store.
1085         * This is only possible if the PersistenceService is available,
1086         * else this call is ignored.
1087         * <p>
1088         * This save may be prevented on grounds of size, etc, by the JNLP runtime.
1089         * <p>
1090         * This should NOT be called by more than one thread at once.
1091         *
1092         * @param aepToSave  AEP to save; usually not null
1093         */
1094        private void cacheAEP(final AllExhibitProperties aepToSave)
1095            throws IOException
1096            {
1097            // Do nothing if not running in JWS/JNLP environment.
1098            if((bs == null) || (ps == null)) { return; }
1099    
1100            final URL aepURL = makeAEPURL(bs);
1101    
1102            // Start with a reasonable size buffer to write into...
1103            int initialBufSize = 65536;
1104            // Try to guess a new larger buffer size from any existing store size...
1105            try { initialBufSize = Math.max(initialBufSize, (int) (AEP_EXPANSION_BYTES + ps.get(aepURL).getLength())); }
1106            catch(final IOException e) { /* Ignore missing store error. */ }
1107    
1108            final ByteArrayOutputStream baos = new ByteArrayOutputStream(initialBufSize);
1109            final ObjectOutputStream ois = new ObjectOutputStream(new GZIPOutputStream(baos));
1110            ois.writeObject(aepToSave);
1111            ois.close();
1112    
1113            // OK, how big is the compressed serialised AEP?
1114            final int len = baos.size();
1115    if(IsDebug.isDebug) { logger.log("AEP save size (bytes) is: " + len); }
1116    
1117            // Create entry for AEP if not yet extant.
1118            try { ps.create(aepURL, len + AEP_EXPANSION_BYTES); /* Enough for current AEP plus a margin for growth... */ }
1119            catch(final IOException e) { } // Ignore errors.
1120    
1121            // Now try to actually persist the data
1122            // by making sure that the entry has enough space granted
1123            // and then overwriting the entry.
1124            final FileContents fc = ps.get(aepURL);
1125            final long available = fc.getMaxLength();
1126            if(available < len)
1127                {
1128                // Currently not enough space available for current AEP...
1129                // so ask for more, in fact lots more than we currently need
1130                // to allow room for expansion without pestering the user too much.
1131                final long granted = fc.setMaxLength(AEP_EXPANSION_BYTES + 2 * len);
1132                if(len > granted)
1133                    { throw new IOException("could not get enough space to cache AEP ("+granted+" vs "+len+")"); }
1134                }
1135            final OutputStream os = fc.getOutputStream(true);
1136            try { baos.writeTo(os); }
1137            finally { os.close(); }
1138            }
1139    
1140        /**Returns true if this (short) exhibit name is valid and unique.
1141         * The name has to be syntactically valid,
1142         * and the short name (and thus the whole name that contains it)
1143         * not currently present in:
1144         * </ul>
1145         * <li>The Gallery exhibit set (the main AEP).
1146         * <li>The uploading-files list.
1147         * <li>The selected-files list.
1148         * </ul>
1149         */
1150        boolean shortNameValidAndUnique(final String newShortName)
1151            {
1152            if(!ExhibitName.validNameFinalComponentSyntax(newShortName)) { return(false); }
1153            if(selectedFiles.shortNameInUse(newShortName)) { return(false); }
1154            if(uploadingFiles.shortNameInUse(newShortName)) { return(false); }
1155            if(aep.aeid.getFullName(newShortName) != null) { return(false); }
1156    
1157            // Seems OK.
1158            return(true);
1159            }
1160    
1161        /**If our name is not unique then increment the number-in-series (if non-zero) until we get a unique name.
1162         * This checks the (short) name against the AEP and other databases.
1163         */
1164        void uibIncNumberIfNotZeroWhileNotUnique(final UploadInfoBean uib)
1165            {
1166            while(uib.getNumber() != 0)
1167                {
1168                final String shortName = uib.getName(false);
1169                if(shortNameValidAndUnique(shortName))
1170                    { break; /* Seems that we now have a unique name! */ }
1171                uib.incNumberIfNonZero();
1172                }
1173            }
1174    
1175    
1176        /**Propose/select/add selected files for upload.
1177         * Takes one or more files somehow selected by the user
1178         * (eg with some sort of GUI front end)
1179         * and a base exhibit name:
1180         * <ul>
1181         * <li>validates/guesses the files' types
1182         * <li>sorts the files into name order
1183         * <li>adds them to the table of names where the user can adjust details
1184         * </ul>
1185         * before asking for the files to be uploaded.
1186         * <p>
1187         * Duplicate entries may be silently discarded.
1188         * <p>
1189         * Package-visible to be callable from GUI classes.
1190         *
1191         * @param autoGuessFormat  if true then the routine may try to guess the
1192         *     type/format of files that do not match the type implied by the name
1193         * @param files  one (or more if the number-in-series is not zero) files
1194         *     that the user has selected for upload;
1195         *     never null not zero-size no containing nulls
1196         * @param uib  initialised with the first/desired name;
1197         *     never null and must be in a state where enoughValidUniqueInfo()==true
1198         *
1199         * @return null if all is well, else an error message (and no files added)
1200         */
1201        String addSelectedFiles(final boolean autoGuessFormat,
1202                                final FileContents files[],
1203                                final UploadInfoBean uib)
1204            {
1205            if((files == null) || (files.length < 1) ||
1206               (uib == null) || !uib.enoughValidUniqueInfo())
1207                { throw new IllegalArgumentException(); }
1208    
1209            final String baseExhibitName = uib.getFullName();
1210            final String extensionComponent = ExhibitName.getExtensionComponent(baseExhibitName).toString();
1211            final ExhibitMIME.ExhibitTypeParameters etp = ExhibitMIME.getExhibitType(extensionComponent);
1212    
1213            try
1214                {
1215                // Sort files into order.
1216                // We use a case-insensitive ordering by name.
1217                Arrays.sort(files, new Comparator<FileContents>(){
1218                    /**Compares its two arguments for order. */
1219                    public final int compare(final FileContents fc1, final FileContents fc2)
1220                        {
1221                        try { return(fc1.getName().compareToIgnoreCase(fc2.getName())); }
1222                        catch(final IOException e)
1223                            {
1224                            final String message = "INTERNAL ERROR: failed to sort file(s) for upload: " + e.getMessage();
1225                            logger.log(message);
1226                            throw new Error(message, e);
1227                            }
1228                        }
1229                    });
1230    
1231                // Validate all the files offered.
1232                // They must be readable and have a correct magic number
1233                // and file-extension if not auto-guessing...
1234                // Deal with them is file-name order.
1235                for(int i = 0; i < files.length; ++i)
1236                    {
1237                    final FileContents fc = files[i];
1238    
1239                    // Skip this local file if already in selected-files list.
1240                    final String name = fc.getName();
1241                    if(selectedFiles.localFilenameInUse(name))
1242                        { continue; }
1243    
1244                    // Is the file readable?
1245                    if(!fc.canRead())
1246                        { return("Cannot read file: " + name); }
1247    
1248                    // Is the file a sensible length?
1249                    if(fc.getLength() < 1)
1250                        { return("File is zero-length: " + name); }
1251    
1252                    // Has file got correct extension (ignoring case).
1253                    final boolean correctExt = extensionComponent.equalsIgnoreCase(
1254                        FileTools.getExtension(name));
1255    
1256                    // Has the file got the correct extension AND magic number?
1257                    // It is an error not to if "auto"/guess mode is not selected.
1258                    final int longestMagicBytes = ExhibitMIME.getLongestMagicBytes();
1259                    final InputStream is = new BufferedInputStream(fc.getInputStream(),
1260                                                                   longestMagicBytes);
1261                    try
1262                        {
1263                        uibIncNumberIfNotZeroWhileNotUnique(uib); // Move to next unique exhibit name if need be.
1264    
1265                        is.mark(longestMagicBytes); // Allow rewind to start of file.
1266                        final boolean isFileTypeOK =
1267                            correctExt &&
1268                            ExhibitMIME.magicOK(etp, is);
1269                        if(isFileTypeOK)
1270                            {
1271                            // Magic is fine (and there *is* a magic number)
1272                            // and file extension is fine (ignoring case);
1273                            // thus file can be added with suggested name
1274                            // and the default/suggested extension.
1275                            uib.setSuffix(etp.dotSuffixForInputFile);
1276                            selectedFiles.add(new SelectedFileDetails(fc, uib.getFullName(), uib.getDescription(), null));
1277                            }
1278                        else if(!autoGuessFormat)
1279                            { return("File may be corrupt or wrong type (try 'auto' mode): " + name); }
1280                        else
1281                            {
1282                            // First try to guess by file extension...
1283                            ExhibitMIME.ExhibitTypeParameters etpGuessed =
1284                                ExhibitMIME.getInputFileType(name.toLowerCase());
1285                            is.reset(); // Rewind ready to check magic...
1286                            final boolean guessedOKFromExtension =
1287                                (etpGuessed != null) &&
1288                                ExhibitMIME.magicOK(etpGuessed, is);
1289    
1290                            // If the file extension does not seem to be right
1291                            // then try to guess just from the file magic number,
1292                            // ie from the file content rather than its name.
1293                            if(!guessedOKFromExtension)
1294                                {
1295                                is.reset(); // Rewind ready to check magic...
1296                                etpGuessed = ExhibitMIME.guessTypeFromMagic(is);
1297                                }
1298    
1299                            // Failed...
1300                            if(etpGuessed == null)
1301                                { return("File type is unrecognised: " + name); }
1302    
1303                            // Need to adjust the exhibit type for this file.
1304                            uib.setSuffix("." + etpGuessed.suffixForInputFile);
1305                            selectedFiles.add(new SelectedFileDetails(fc, uib.getFullName(), uib.getDescription(), null));
1306                            }
1307                        }
1308                    finally { is.close(); }
1309                    }
1310                }
1311            catch(final Exception e)
1312                {
1313                e.printStackTrace();
1314                logger.log("Failed to open file(s) for upload: " + e.getMessage());
1315    
1316                // Reset the name to the original in case of error.
1317                uib.setFullName(baseExhibitName);
1318    
1319                return("Cannot upload: " + e.getMessage());
1320                }
1321    
1322            return(null); // OK!
1323            }
1324    
1325    
1326    
1327    
1328        /**Save persistent properties (if possible).
1329         * If we have PersistenceService then
1330         * try to create/allocate space to save (ignoring errors if already there)
1331         * and save the data.
1332         */
1333        private void saveProperties()
1334            {
1335            if((ps != null) && (bs != null))
1336                {
1337                try
1338                    {
1339                    final URL propsURL = makePropsURL(bs);
1340    
1341                    // Try to create a space for persisting the data
1342                    // if not already present (ignoring error if it is).
1343                    try { ps.create(propsURL, UploaderProps.MAX_PERS_BYTES); }
1344                    catch(final IOException e) { }
1345    
1346                    // Now try to save our properties data.
1347                    final FileContents fc = ps.get(propsURL);
1348                    final OutputStream os = fc.getOutputStream(true);
1349                    try { os.write(props.getPersistentData()); }
1350                    finally { os.close(); }
1351    
1352    if(IsDebug.isDebug) { logger.log("Saved properties..."); }
1353                    }
1354                catch(final IOException e)
1355                    {
1356                    e.printStackTrace();
1357                    logger.log("Cannot save properties: " + e.getMessage());
1358                    }
1359                }
1360            }
1361    
1362    
1363        /**Perform any activity required to shut down cleanly, eg save state.
1364         * This should try to avoid taking a long time.
1365         * <p>
1366         * Package-visible so as to be directly usable by the main GUI class.
1367         */
1368        void shutdown()
1369            {
1370            // Save any state...
1371            saveProperties();
1372    
1373            // Shut down the tunnel last.
1374            if(tunnel != null) { tunnel.destroy(); }
1375            }
1376        }