Statistics
| Branch: | Tag: | Revision:

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

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.FileInputStream;
52
import java.io.FileOutputStream;
53
import java.io.IOException;
54
import java.io.InputStream;
55
import java.io.OutputStream;
56
import java.math.BigInteger;
57
import java.security.MessageDigest;
58
import java.security.NoSuchAlgorithmException;
59
import java.security.cert.Certificate;
60
import java.security.cert.CertificateEncodingException;
61
import java.text.ParseException;
62
import java.text.SimpleDateFormat;
63
import java.util.Date;
64
import java.util.Formatter;
65
import java.util.Iterator;
66
import java.util.List;
67
import java.util.Locale;
68

    
69
public final class Utils {
70

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

    
74
    public static final int BUFFER_SIZE = 4096;
75

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
678
}