Statistics
| Branch: | Tag: | Revision:

fdroidclient / F-Droid / src / org / fdroid / fdroid / Utils.java @ a09587c7

History | View | Annotate | Download (23.2 KB)

1
/*
2
 * Copyright (C) 2010-12  Ciaran Gultnieks, ciaran@ciarang.com
3
 *
4
 * This program is free software; you can redistribute it and/or
5
 * modify it under the terms of the GNU General Public License
6
 * as published by the Free Software Foundation; either version 3
7
 * of the License, or (at your option) any later version.
8
 *
9
 * This program is distributed in the hope that it will be useful,
10
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
 * GNU General Public License for more details.
13
 *
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program; if not, write to the Free Software
16
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17
 */
18

    
19
package org.fdroid.fdroid;
20

    
21
import android.content.Context;
22
import android.content.pm.PackageManager;
23
import android.content.res.AssetManager;
24
import android.content.res.XmlResourceParser;
25
import android.graphics.Bitmap;
26
import android.net.Uri;
27
import android.support.annotation.NonNull;
28
import android.support.annotation.Nullable;
29
import android.text.Editable;
30
import android.text.Html;
31
import android.text.TextUtils;
32
import android.util.DisplayMetrics;
33
import android.util.Log;
34

    
35
import com.nostra13.universalimageloader.core.DisplayImageOptions;
36
import com.nostra13.universalimageloader.core.assist.ImageScaleType;
37
import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer;
38
import com.nostra13.universalimageloader.utils.StorageUtils;
39

    
40
import org.fdroid.fdroid.compat.FileCompat;
41
import org.fdroid.fdroid.data.Apk;
42
import org.fdroid.fdroid.data.Repo;
43
import org.fdroid.fdroid.data.SanitizedFile;
44
import org.xml.sax.XMLReader;
45
import org.xmlpull.v1.XmlPullParser;
46
import org.xmlpull.v1.XmlPullParserException;
47

    
48
import java.io.BufferedInputStream;
49
import java.io.Closeable;
50
import java.io.File;
51
import java.io.FileDescriptor;
52
import java.io.FileInputStream;
53
import java.io.FileOutputStream;
54
import java.io.IOException;
55
import java.io.InputStream;
56
import java.io.OutputStream;
57
import java.math.BigInteger;
58
import java.security.MessageDigest;
59
import java.security.NoSuchAlgorithmException;
60
import java.security.cert.Certificate;
61
import java.security.cert.CertificateEncodingException;
62
import java.text.ParseException;
63
import java.text.SimpleDateFormat;
64
import java.util.Date;
65
import java.util.Formatter;
66
import java.util.Iterator;
67
import java.util.List;
68
import java.util.Locale;
69

    
70
public final class Utils {
71

    
72
    @SuppressWarnings("UnusedDeclaration")
73
    private static final String TAG = "Utils";
74

    
75
    public static final int BUFFER_SIZE = 4096;
76

    
77
    // The date format used for storing dates (e.g. lastupdated, added) in the
78
    // database.
79
    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH);
80

    
81
    private static final String[] FRIENDLY_SIZE_FORMAT = {
82
            "%.0f B", "%.0f KiB", "%.1f MiB", "%.2f GiB" };
83

    
84
    private static final SimpleDateFormat LOG_DATE_FORMAT =
85
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
86

    
87
    public static final String FALLBACK_ICONS_DIR = "/icons/";
88

    
89
    /*
90
     * @param dpiMultiplier Lets you grab icons for densities larger or
91
     * smaller than that of your device by some fraction. Useful, for example,
92
     * if you want to display a 48dp image at twice the size, 96dp, in which
93
     * case you'd use a dpiMultiplier of 2.0 to get an image twice as big.
94
     */
95
    public static String getIconsDir(final Context context, final double dpiMultiplier) {
96
        final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
97
        final double dpi = metrics.densityDpi * dpiMultiplier;
98
        if (dpi >= 640) {
99
            return "/icons-640/";
100
        }
101
        if (dpi >= 480) {
102
            return "/icons-480/";
103
        }
104
        if (dpi >= 320) {
105
            return "/icons-320/";
106
        }
107
        if (dpi >= 240) {
108
            return "/icons-240/";
109
        }
110
        if (dpi >= 160) {
111
            return "/icons-160/";
112
        }
113

    
114
        return "/icons-120/";
115
    }
116

    
117
    public static void copy(InputStream input, OutputStream output)
118
            throws IOException {
119
        copy(input, output, null, null);
120
    }
121

    
122
    public static void copy(InputStream input, OutputStream output,
123
                    ProgressListener progressListener,
124
                    ProgressListener.Event templateProgressEvent)
125
    throws IOException {
126
        byte[] buffer = new byte[BUFFER_SIZE];
127
        int bytesRead = 0;
128
        while (true) {
129
            int count = input.read(buffer);
130
            if (count == -1) {
131
                break;
132
            }
133
            if (progressListener != null) {
134
                bytesRead += count;
135
                templateProgressEvent.progress = bytesRead;
136
                progressListener.onProgress(templateProgressEvent);
137
            }
138
            output.write(buffer, 0, count);
139
        }
140
        output.flush();
141
    }
142

    
143
    /**
144
     * Attempt to symlink, but if that fails, it will make a copy of the file.
145
     */
146
    public static boolean symlinkOrCopyFile(SanitizedFile inFile, SanitizedFile outFile) {
147
        return FileCompat.symlink(inFile, outFile) || copy(inFile, outFile);
148
    }
149

    
150
    /**
151
     * Read the input stream until it reaches the end, ignoring any exceptions.
152
     */
153
    public static void consumeStream(InputStream stream) {
154
        final byte[] buffer = new byte[256];
155
        try {
156
            int read;
157
            do {
158
                read = stream.read(buffer);
159
            } while (read != -1);
160
        } catch (IOException e) {
161
            // Ignore...
162
        }
163
    }
164

    
165
    public static boolean copy(File inFile, File outFile) {
166
        InputStream input = null;
167
        OutputStream output = null;
168
        try {
169
            input  = new FileInputStream(inFile);
170
            output = new FileOutputStream(outFile);
171
            Utils.copy(input, output);
172
            return true;
173
        } catch (IOException e) {
174
            Log.e(TAG, "I/O error when copying a file", e);
175
            return false;
176
        } finally {
177
            closeQuietly(output);
178
            closeQuietly(input);
179
        }
180
    }
181

    
182
    public static void closeQuietly(Closeable closeable) {
183
        if (closeable == null) {
184
            return;
185
        }
186
        try {
187
            closeable.close();
188
        } catch (IOException ioe) {
189
            // ignore
190
        }
191
    }
192

    
193
    public static String getFriendlySize(int size) {
194
        double s = size;
195
        int i = 0;
196
        while (i < FRIENDLY_SIZE_FORMAT.length - 1 && s >= 1024) {
197
            s = (100 * s / 1024) / 100.0;
198
            i++;
199
        }
200
        return String.format(FRIENDLY_SIZE_FORMAT[i], s);
201
    }
202

    
203
    private static final String[] androidVersionNames = {
204
        "?",     // 0, undefined
205
        "1.0",   // 1
206
        "1.1",   // 2
207
        "1.5",   // 3
208
        "1.6",   // 4
209
        "2.0",   // 5
210
        "2.0.1", // 6
211
        "2.1",   // 7
212
        "2.2",   // 8
213
        "2.3",   // 9
214
        "2.3.3", // 10
215
        "3.0",   // 11
216
        "3.1",   // 12
217
        "3.2",   // 13
218
        "4.0",   // 14
219
        "4.0.3", // 15
220
        "4.1",   // 16
221
        "4.2",   // 17
222
        "4.3",   // 18
223
        "4.4",   // 19
224
        "4.4W",  // 20
225
        "5.0",   // 21
226
        "5.1",   // 22
227
        "6.0"    // 23
228
    };
229

    
230
    public static String getAndroidVersionName(int sdkLevel) {
231
        if (sdkLevel < 0) {
232
            return androidVersionNames[0];
233
        }
234
        if (sdkLevel >= androidVersionNames.length) {
235
            return String.format(Locale.ENGLISH, "v%d", sdkLevel);
236
        }
237
        return androidVersionNames[sdkLevel];
238
    }
239

    
240
    /* PackageManager doesn't give us the min and max sdk versions, so we have
241
     * to parse it */
242
    private static int getMinMaxSdkVersion(Context context, String packageName,
243
            String attrName) {
244
        try {
245
            AssetManager am = context.createPackageContext(packageName, 0).getAssets();
246
            XmlResourceParser xml = am.openXmlResourceParser("AndroidManifest.xml");
247
            int eventType = xml.getEventType();
248
            while (eventType != XmlPullParser.END_DOCUMENT) {
249
                if (eventType == XmlPullParser.START_TAG && xml.getName().equals("uses-sdk")) {
250
                    for (int j = 0; j < xml.getAttributeCount(); j++) {
251
                        if (xml.getAttributeName(j).equals(attrName)) {
252
                            return Integer.parseInt(xml.getAttributeValue(j));
253
                        }
254
                    }
255
                }
256
                eventType = xml.nextToken();
257
            }
258
        } catch (PackageManager.NameNotFoundException | IOException | XmlPullParserException e) {
259
            Log.e(TAG, "Could not get min/max sdk version", e);
260
        }
261
        return 0;
262
    }
263

    
264
    public static int getMinSdkVersion(Context context, String packageName) {
265
        return getMinMaxSdkVersion(context, packageName, "minSdkVersion");
266
    }
267

    
268
    public static int getMaxSdkVersion(Context context, String packageName) {
269
        return getMinMaxSdkVersion(context, packageName, "maxSdkVersion");
270
    }
271

    
272
    // return a fingerprint formatted for display
273
    public static String formatFingerprint(Context context, String fingerprint) {
274
        if (TextUtils.isEmpty(fingerprint)
275
                || fingerprint.length() != 64  // SHA-256 is 64 hex chars
276
                || fingerprint.matches(".*[^0-9a-fA-F].*")) // its a hex string
277
            return context.getString(R.string.bad_fingerprint);
278
        String displayFP = fingerprint.substring(0, 2);
279
        for (int i = 2; i < fingerprint.length(); i = i + 2)
280
            displayFP += " " + fingerprint.substring(i, i + 2);
281
        return displayFP;
282
    }
283

    
284
    @NonNull
285
    public static Uri getLocalRepoUri(Repo repo) {
286
        if (TextUtils.isEmpty(repo.address))
287
            return Uri.parse("http://wifi-not-enabled");
288
        Uri uri = Uri.parse(repo.address);
289
        Uri.Builder b = uri.buildUpon();
290
        if (!TextUtils.isEmpty(repo.fingerprint))
291
            b.appendQueryParameter("fingerprint", repo.fingerprint);
292
        String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https" : "http";
293
        b.scheme(scheme);
294
        return b.build();
295
    }
296

    
297
    public static Uri getSharingUri(Repo repo) {
298
        if (TextUtils.isEmpty(repo.address))
299
            return Uri.parse("http://wifi-not-enabled");
300
        Uri localRepoUri = getLocalRepoUri(repo);
301
        Uri.Builder b = localRepoUri.buildUpon();
302
        b.scheme(localRepoUri.getScheme().replaceFirst("http", "fdroidrepo"));
303
        b.appendQueryParameter("swap", "1");
304
        if (!TextUtils.isEmpty(FDroidApp.bssid)) {
305
            b.appendQueryParameter("bssid", Uri.encode(FDroidApp.bssid));
306
            if (!TextUtils.isEmpty(FDroidApp.ssid))
307
                b.appendQueryParameter("ssid", Uri.encode(FDroidApp.ssid));
308
        }
309
        return b.build();
310
    }
311

    
312
    /**
313
     * See {@link Utils#getApkDownloadDir(android.content.Context)} for why this is "unsafe".
314
     */
315
    public static SanitizedFile getApkCacheDir(Context context) {
316
        final SanitizedFile apkCacheDir = new SanitizedFile(StorageUtils.getCacheDirectory(context, true), "apks");
317
        if (!apkCacheDir.exists()) {
318
            apkCacheDir.mkdir();
319
        }
320
        return apkCacheDir;
321
    }
322

    
323
    /**
324
     * The directory where .apk files are downloaded (and stored - if the relevant property is enabled).
325
     * This must be on internal storage, to prevent other apps with "write external storage" from being
326
     * able to change the .apk file between F-Droid requesting the Package Manger to install, and the
327
     * Package Manager receiving that request.
328
     */
329
    public static File getApkDownloadDir(Context context) {
330
        final SanitizedFile apkCacheDir = new SanitizedFile(StorageUtils.getCacheDirectory(context, false), "temp");
331
        if (!apkCacheDir.exists()) {
332
            apkCacheDir.mkdir();
333
        }
334

    
335
        // All parent directories of the .apk file need to be executable for the package installer
336
        // to be able to have permission to read our world-readable .apk files.
337
        FileCompat.setExecutable(apkCacheDir, true, false);
338
        return apkCacheDir;
339
    }
340

    
341
    public static String calcFingerprint(String keyHexString) {
342
        if (TextUtils.isEmpty(keyHexString)
343
                || keyHexString.matches(".*[^a-fA-F0-9].*")) {
344
            Log.e(TAG, "Signing key certificate was blank or contained a non-hex-digit!");
345
            return null;
346
        }
347
        return calcFingerprint(Hasher.unhex(keyHexString));
348
    }
349

    
350
    public static String calcFingerprint(Certificate cert) {
351
        if (cert == null)
352
            return null;
353
        try {
354
            return calcFingerprint(cert.getEncoded());
355
        } catch (CertificateEncodingException e) {
356
            return null;
357
        }
358
    }
359

    
360
    public static String calcFingerprint(byte[] key) {
361
        if (key == null)
362
            return null;
363
        String ret = null;
364
        if (key.length < 256) {
365
            Log.e(TAG, "key was shorter than 256 bytes (" + key.length + "), cannot be valid!");
366
            return null;
367
        }
368
        try {
369
            // keytool -list -v gives you the SHA-256 fingerprint
370
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
371
            digest.update(key);
372
            byte[] fingerprint = digest.digest();
373
            Formatter formatter = new Formatter(new StringBuilder());
374
            for (byte aFingerprint : fingerprint) {
375
                formatter.format("%02X", aFingerprint);
376
            }
377
            ret = formatter.toString();
378
            formatter.close();
379
        } catch (Exception e) {
380
            Log.w(TAG, "Unable to get certificate fingerprint", e);
381
        }
382
        return ret;
383
    }
384

    
385
    /**
386
     * There is a method {@link java.util.Locale#forLanguageTag(String)} which would be useful
387
     * for this, however it doesn't deal with android-specific language tags, which are a little
388
     * different. For example, android language tags may have an "r" before the country code,
389
     * such as "zh-rHK", however {@link java.util.Locale} expects them to be "zr-HK".
390
     */
391
    public static Locale getLocaleFromAndroidLangTag(String languageTag) {
392
        if (TextUtils.isEmpty(languageTag)) {
393
            return null;
394
        }
395

    
396
        final String[] parts = languageTag.split("-");
397
        if (parts.length == 1) {
398
            return new Locale(parts[0]);
399
        }
400
        if (parts.length == 2) {
401
            String country = parts[1];
402
            // Some languages have an "r" before the country as per the values folders, such
403
            // as "zh-rCN". As far as the Locale class is concerned, the "r" is
404
            // not helpful, and this should be "zh-CN". Thus, we will
405
            // strip the "r" when found.
406
            if (country.charAt(0) == 'r' && country.length() == 3) {
407
                country = country.substring(1);
408
            }
409
            return new Locale(parts[0], country);
410
        }
411
        Log.e(TAG, "Locale could not be parsed from language tag: " + languageTag);
412
        return new Locale(languageTag);
413
    }
414

    
415
    public static String getApkUrl(Apk apk) {
416
        return getApkUrl(apk.repoAddress, apk);
417
    }
418

    
419
    public static String getApkUrl(String repoAddress, Apk apk) {
420
        return repoAddress + "/" + apk.apkName.replace(" ", "%20");
421
    }
422

    
423
    public static class CommaSeparatedList implements Iterable<String> {
424
        private final String value;
425

    
426
        private CommaSeparatedList(String list) {
427
            value = list;
428
        }
429

    
430
        public static CommaSeparatedList make(List<String> list) {
431
            if (list == null || list.size() == 0)
432
                return null;
433
            StringBuilder sb = new StringBuilder();
434
            for (int i = 0; i < list.size(); i++) {
435
                if (i > 0) {
436
                    sb.append(',');
437
                }
438
                sb.append(list.get(i));
439
            }
440
            return new CommaSeparatedList(sb.toString());
441
        }
442

    
443
        public static CommaSeparatedList make(String[] list) {
444
            if (list == null || list.length == 0)
445
                return null;
446
            StringBuilder sb = new StringBuilder();
447
            for (int i = 0; i < list.length; i++) {
448
                if (i > 0) {
449
                    sb.append(',');
450
                }
451
                sb.append(list[i]);
452
            }
453
            return new CommaSeparatedList(sb.toString());
454
        }
455

    
456
        @Nullable
457
        public static CommaSeparatedList make(@Nullable String list) {
458
            if (TextUtils.isEmpty(list))
459
                return null;
460
            return new CommaSeparatedList(list);
461
        }
462

    
463
        public static String str(CommaSeparatedList instance) {
464
            return (instance == null ? null : instance.toString());
465
        }
466

    
467
        @Override
468
        public String toString() {
469
            return value;
470
        }
471

    
472
        public String toPrettyString() {
473
            return value.replaceAll(",", ", ");
474
        }
475

    
476
        @Override
477
        public Iterator<String> iterator() {
478
            TextUtils.SimpleStringSplitter splitter = new TextUtils.SimpleStringSplitter(',');
479
            splitter.setString(value);
480
            return splitter.iterator();
481
        }
482

    
483
        public boolean contains(String v) {
484
            for (final String s : this) {
485
                if (s.equals(v))
486
                    return true;
487
            }
488
            return false;
489
        }
490
    }
491

    
492
    public static DisplayImageOptions.Builder getImageLoadingOptions() {
493
        return new DisplayImageOptions.Builder()
494
                .cacheInMemory(true)
495
                .cacheOnDisk(true)
496
                .imageScaleType(ImageScaleType.NONE)
497
                .showImageOnLoading(R.drawable.ic_repo_app_default)
498
                .showImageForEmptyUri(R.drawable.ic_repo_app_default)
499
                .displayer(new FadeInBitmapDisplayer(200, true, true, false))
500
                .bitmapConfig(Bitmap.Config.RGB_565);
501
    }
502

    
503
    // this is all new stuff being added
504
    public static String hashBytes(byte[] input, String algo) {
505
        try {
506
            MessageDigest md = MessageDigest.getInstance(algo);
507
            byte[] hashBytes = md.digest(input);
508
            String hash = toHexString(hashBytes);
509

    
510
            md.reset();
511
            return hash;
512
        } catch (NoSuchAlgorithmException e) {
513
            Log.e(TAG, "Device does not support " + algo + " MessageDisgest algorithm");
514
            return null;
515
        }
516
    }
517

    
518
    public static String getBinaryHash(File apk, String algo) {
519
        FileInputStream fis = null;
520
        try {
521
            MessageDigest md = MessageDigest.getInstance(algo);
522
            fis = new FileInputStream(apk);
523
            BufferedInputStream bis = new BufferedInputStream(fis);
524

    
525
            byte[] dataBytes = new byte[524288];
526
            int nread;
527
            while ((nread = bis.read(dataBytes)) != -1)
528
                md.update(dataBytes, 0, nread);
529

    
530
            byte[] mdbytes = md.digest();
531
            return toHexString(mdbytes);
532
        } catch (IOException e) {
533
            Log.e(TAG, "Error reading \"" + apk.getAbsolutePath()
534
                    + "\" to compute " + algo + " hash.");
535
            return null;
536
        } catch (NoSuchAlgorithmException e) {
537
            Log.e(TAG, "Device does not support " + algo + " MessageDisgest algorithm");
538
            return null;
539
        } finally {
540
            closeQuietly(fis);
541
        }
542
    }
543

    
544
    /**
545
     * Computes the base 16 representation of the byte array argument.
546
     *
547
     * @param bytes an array of bytes.
548
     * @return the bytes represented as a string of hexadecimal digits.
549
     */
550
    public static String toHexString(byte[] bytes) {
551
        BigInteger bi = new BigInteger(1, bytes);
552
        return String.format("%0" + (bytes.length << 1) + "X", bi);
553
    }
554

    
555
    public static int parseInt(String str, int fallback) {
556
        if (str == null || str.length() == 0) {
557
            return fallback;
558
        }
559
        int result;
560
        try {
561
            result = Integer.parseInt(str);
562
        } catch (NumberFormatException e) {
563
            result = fallback;
564
        }
565
        return result;
566
    }
567

    
568
    public static Date parseDate(String str, Date fallback) {
569
        if (str == null || str.length() == 0) {
570
            return fallback;
571
        }
572
        Date result;
573
        try {
574
            result = DATE_FORMAT.parse(str);
575
        } catch (ParseException e) {
576
            result = fallback;
577
        }
578
        return result;
579
    }
580

    
581
    public static String formatDate(Date date, String fallback) {
582
        if (date == null) {
583
            return fallback;
584
        }
585
        return DATE_FORMAT.format(date);
586
    }
587

    
588
    public static String formatLogDate(Date date) {
589
        if (date == null) {
590
            return "(unknown)";
591
        }
592
        return LOG_DATE_FORMAT.format(date);
593
    }
594

    
595
    // Need this to add the unimplemented support for ordered and unordered
596
    // lists to Html.fromHtml().
597
    public static class HtmlTagHandler implements Html.TagHandler {
598
        int listNum;
599

    
600
        @Override
601
        public void handleTag(boolean opening, String tag, Editable output,
602
                XMLReader reader) {
603
            switch (tag) {
604
            case "ul":
605
                if (opening)
606
                    listNum = -1;
607
                else
608
                    output.append('\n');
609
                break;
610
            case "ol":
611
                if (opening)
612
                    listNum = 1;
613
                else
614
                    output.append('\n');
615
                break;
616
            case "li":
617
                if (opening) {
618
                    if (listNum == -1) {
619
                        output.append("\t");
620
                    } else {
621
                        output.append("\t").append(Integer.toString(listNum)).append(". ");
622
                        listNum++;
623
                    }
624
                } else {
625
                    output.append('\n');
626
                }
627
                break;
628
            }
629
        }
630
    }
631

    
632
    /**
633
     * Remove all files from the {@parm directory} either beginning with {@param startsWith}
634
     * or ending with {@param endsWith}. Note that if the SD card is not ready, then the
635
     * cache directory will probably not be available. In this situation no files will be
636
     * deleted (and thus they may still exist after the SD card becomes available).
637
     */
638
    public static void deleteFiles(@Nullable File directory, @Nullable String startsWith, @Nullable String endsWith) {
639

    
640
        if (directory == null) {
641
            return;
642
        }
643

    
644
        final File[] files = directory.listFiles();
645
        if (files == null) {
646
            return;
647
        }
648

    
649
        if (startsWith != null) {
650
            DebugLog(TAG, "Cleaning up files in " + directory + " that start with \"" + startsWith + "\"");
651
        }
652

    
653
        if (endsWith != null) {
654
            DebugLog(TAG, "Cleaning up files in " + directory + " that end with \"" + endsWith + "\"");
655
        }
656

    
657
        for (File f : files) {
658
            if ((startsWith != null && f.getName().startsWith(startsWith))
659
                || (endsWith != null && f.getName().endsWith(endsWith))) {
660
                if (!f.delete()) {
661
                    Log.w(TAG, "Couldn't delete cache file " + f);
662
                }
663
            }
664
        }
665
    }
666

    
667
    public static void DebugLog(String tag, String msg) {
668
        if (BuildConfig.DEBUG) {
669
            Log.d(tag, msg);
670
        }
671
    }
672

    
673
    public static void DebugLog(String tag, String msg, Throwable tr) {
674
        if (BuildConfig.DEBUG) {
675
            Log.d(tag, msg, tr);
676
        }
677
    }
678

    
679
}