summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
blob: 27ec4f1dd6af32d501647d9815a368fc43d08bd7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko;

import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
import org.mozilla.gecko.GeckoProfileDirectories.NoSuchProfileException;
import org.mozilla.gecko.annotation.RobocopTarget;
import org.mozilla.gecko.util.FileUtils;
import org.mozilla.gecko.util.INIParser;
import org.mozilla.gecko.util.INISection;
import org.mozilla.gecko.util.IntentUtils;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class GeckoProfile {
    private static final String LOGTAG = "GeckoProfile";

    // The path in the profile to the file containing the client ID.
    private static final String CLIENT_ID_FILE_PATH = "datareporting/state.json";
    private static final String FHR_CLIENT_ID_FILE_PATH = "healthreport/state.json";
    // In the client ID file, the attribute title in the JSON object containing the client ID value.
    private static final String CLIENT_ID_JSON_ATTR = "clientID";

    private static final String TIMES_PATH = "times.json";
    private static final String PROFILE_CREATION_DATE_JSON_ATTR = "created";

    // Only tests should need to do this.
    // We can default this to AppConstants.RELEASE_OR_BETA once we fix Bug 1069687.
    private static volatile boolean sAcceptDirectoryChanges = true;

    @RobocopTarget
    public static void enableDirectoryChanges() {
        Log.w(LOGTAG, "Directory changes should only be enabled for tests. And even then it's a bad idea.");
        sAcceptDirectoryChanges = true;
    }

    public static final String DEFAULT_PROFILE = "default";
    // Profile is using a custom directory outside of the Mozilla directory.
    public static final String CUSTOM_PROFILE = "";

    public static final String GUEST_PROFILE_DIR = "guest";
    public static final String GUEST_MODE_PREF = "guestMode";

    // Session store
    private static final String SESSION_FILE = "sessionstore.js";
    private static final String SESSION_FILE_BACKUP = "sessionstore.bak";
    private static final String SESSION_FILE_PREVIOUS = "sessionstore.old";
    private static final long MAX_PREVIOUS_FILE_AGE = 1000 * 3600 * 24; // 24 hours

    private boolean mOldSessionDataProcessed = false;

    private static final ConcurrentHashMap<String, GeckoProfile> sProfileCache =
            new ConcurrentHashMap<String, GeckoProfile>(
                    /* capacity */ 4, /* load factor */ 0.75f, /* concurrency */ 2);
    private static String sDefaultProfileName;

    private final String mName;
    private final File mMozillaDir;
    private final Context mApplicationContext;

    private Object mData;

    /**
     * Access to this member should be synchronized to avoid
     * races during creation -- particularly between getDir and GeckoView#init.
     *
     * Not final because this is lazily computed.
     */
    private File mProfileDir;

    private Boolean mInGuestMode;

    public static boolean shouldUseGuestMode(final Context context) {
        return GeckoSharedPrefs.forApp(context).getBoolean(GUEST_MODE_PREF, false);
    }

    public static void enterGuestMode(final Context context) {
        GeckoSharedPrefs.forApp(context).edit().putBoolean(GUEST_MODE_PREF, true).commit();
    }

    public static void leaveGuestMode(final Context context) {
        GeckoSharedPrefs.forApp(context).edit().putBoolean(GUEST_MODE_PREF, false).commit();
    }

    public static GeckoProfile initFromArgs(final Context context, final String args) {
        if (shouldUseGuestMode(context)) {
            final GeckoProfile guestProfile = getGuestProfile(context);
            if (guestProfile != null) {
                return guestProfile;
            }
            // Failed to create guest profile; leave guest mode.
            leaveGuestMode(context);
        }

        // We never want to use the guest mode profile concurrently with a normal profile
        // -- no syncing to it, no dual-profile usage, nothing.  GeckoThread startup with
        // a conventional GeckoProfile will cause the guest profile to be deleted and
        // guest mode to reset.
        if (getGuestDir(context).isDirectory()) {
            final GeckoProfile guestProfile = getGuestProfile(context);
            if (guestProfile != null) {
                removeProfile(context, guestProfile);
            }
        }

        String profileName = null;
        String profilePath = null;

        if (args != null && args.contains("-P")) {
            final Pattern p = Pattern.compile("(?:-P\\s*)(\\w*)(\\s*)");
            final Matcher m = p.matcher(args);
            if (m.find()) {
                profileName = m.group(1);
            }
        }

        if (args != null && args.contains("-profile")) {
            final Pattern p = Pattern.compile("(?:-profile\\s*)(\\S*)(\\s*)");
            final Matcher m = p.matcher(args);
            if (m.find()) {
                profilePath =  m.group(1);
            }
        }

        if (profileName == null && profilePath == null) {
            // Get the default profile for the Activity.
            return getDefaultProfile(context);
        }

        return GeckoProfile.get(context, profileName, profilePath);
    }

    private static GeckoProfile getDefaultProfile(Context context) {
        try {
            return get(context, getDefaultProfileName(context));

        } catch (final NoMozillaDirectoryException e) {
            // If this failed, we're screwed.
            Log.wtf(LOGTAG, "Unable to get default profile name.", e);
            throw new RuntimeException(e);
        }
    }

    public static GeckoProfile get(Context context) {
        return get(context, null, (File) null);
    }

    public static GeckoProfile get(Context context, String profileName) {
        if (profileName != null) {
            GeckoProfile profile = sProfileCache.get(profileName);
            if (profile != null)
                return profile;
        }
        return get(context, profileName, (File)null);
    }

    @RobocopTarget
    public static GeckoProfile get(Context context, String profileName, String profilePath) {
        File dir = null;
        if (!TextUtils.isEmpty(profilePath)) {
            dir = new File(profilePath);
            if (!dir.exists() || !dir.isDirectory()) {
                Log.w(LOGTAG, "requested profile directory missing: " + profilePath);
            }
        }
        return get(context, profileName, dir);
    }

    // Note that the profile cache respects only the profile name!
    // If the directory changes, the returned GeckoProfile instance will be mutated.
    @RobocopTarget
    public static GeckoProfile get(Context context, String profileName, File profileDir) {
        if (context == null) {
            throw new IllegalArgumentException("context must be non-null");
        }

        // Null name? | Null dir? | Returned profile
        // ------------------------------------------
        //     Yes    |    Yes    | Active profile or default profile.
        //     No     |    Yes    | Profile with specified name at default dir.
        //     Yes    |    No     | Custom (anonymous) profile with specified dir.
        //     No     |    No     | Profile with specified name at specified dir.

        if (profileName == null && profileDir == null) {
            // If no profile info was passed in, look for the active profile or a default profile.
            final GeckoProfile profile = GeckoThread.getActiveProfile();
            if (profile != null) {
                return profile;
            }

            final String args;
            if (context instanceof Activity) {
                args = IntentUtils.getStringExtraSafe(((Activity) context).getIntent(), "args");
            } else {
                args = null;
            }

            return GeckoProfile.initFromArgs(context, args);

        } else if (profileName == null) {
            // If only profile dir was passed in, use custom (anonymous) profile.
            profileName = CUSTOM_PROFILE;

        } else if (AppConstants.DEBUG_BUILD) {
            Log.v(LOGTAG, "Fetching profile: '" + profileName + "', '" + profileDir + "'");
        }

        // We require the profile dir to exist if specified, so create it here if needed.
        final boolean init = profileDir != null && profileDir.mkdirs();

        // Actually try to look up the profile.
        GeckoProfile profile = sProfileCache.get(profileName);
        GeckoProfile newProfile = null;

        if (profile == null) {
            try {
                newProfile = new GeckoProfile(context, profileName, profileDir);
            } catch (NoMozillaDirectoryException e) {
                // We're unable to do anything sane here.
                throw new RuntimeException(e);
            }

            profile = sProfileCache.putIfAbsent(profileName, newProfile);
        }

        if (profile == null) {
            profile = newProfile;

        } else if (profileDir != null) {
            // We have an existing profile but was given an alternate directory.
            boolean consistent = false;
            try {
                consistent = profile.mProfileDir != null &&
                        profile.mProfileDir.getCanonicalPath().equals(profileDir.getCanonicalPath());
            } catch (final IOException e) {
            }

            if (!consistent) {
                if (!sAcceptDirectoryChanges || !profileDir.isDirectory()) {
                    throw new IllegalStateException(
                            "Refusing to reuse profile with a different directory.");
                }

                if (AppConstants.RELEASE_OR_BETA) {
                    Log.e(LOGTAG, "Release build trying to switch out profile dir. " +
                                  "This is an error, but let's do what we can.");
                }
                profile.setDir(profileDir);
            }
        }

        if (init) {
            // Initialize the profile directory if we had to create it.
            profile.enqueueInitialization(profileDir);
        }

        return profile;
    }

    // Currently unused outside of testing.
    @RobocopTarget
    public static boolean removeProfile(final Context context, final GeckoProfile profile) {
        final boolean success = profile.remove();

        if (success) {
            // Clear all shared prefs for the given profile.
            GeckoSharedPrefs.forProfileName(context, profile.getName())
                            .edit().clear().apply();
        }

        return success;
    }

    private static File getGuestDir(final Context context) {
        return context.getFileStreamPath(GUEST_PROFILE_DIR);
    }

    @RobocopTarget
    public static GeckoProfile getGuestProfile(final Context context) {
        return get(context, CUSTOM_PROFILE, getGuestDir(context));
    }

    public static boolean isGuestProfile(final Context context, final String profileName,
                                         final File profileDir) {
        // Guest profile is just a custom profile with a special path.
        if (profileDir == null || !CUSTOM_PROFILE.equals(profileName)) {
            return false;
        }

        try {
            return profileDir.getCanonicalPath().equals(getGuestDir(context).getCanonicalPath());
        } catch (final IOException e) {
            return false;
        }
    }

    private GeckoProfile(Context context, String profileName, File profileDir) throws NoMozillaDirectoryException {
        if (profileName == null) {
            throw new IllegalArgumentException("Unable to create GeckoProfile for empty profile name.");
        } else if (CUSTOM_PROFILE.equals(profileName) && profileDir == null) {
            throw new IllegalArgumentException("Custom profile must have a directory");
        }

        mApplicationContext = context.getApplicationContext();
        mName = profileName;
        mMozillaDir = GeckoProfileDirectories.getMozillaDirectory(context);

        mProfileDir = profileDir;
        if (profileDir != null && !profileDir.isDirectory()) {
            throw new IllegalArgumentException("Profile directory must exist if specified.");
        }
    }

    /**
     * Return the custom data object associated with this profile, which was set by the
     * previous {@link #setData(Object)} call. This association is valid for the duration
     * of the process lifetime. The caller must ensure proper synchronization, typically
     * by synchronizing on the object returned by {@link #getLock()}.
     *
     * The data object is usually a database object that stores per-profile data such as
     * page history. However, it can be any other object that needs to maintain
     * profile-specific state.
     *
     * @return Associated data object
     */
    public Object getData() {
        return mData;
    }

    /**
     * Associate this profile with a custom data object, which can be retrieved by
     * subsequent {@link #getData()} calls. The caller must ensure proper
     * synchronization, typically by synchronizing on the object returned by {@link
     * #getLock()}.
     *
     * @param data Custom data object
     */
    public void setData(final Object data) {
        mData = data;
    }

    private void setDir(File dir) {
        if (dir != null && dir.exists() && dir.isDirectory()) {
            synchronized (this) {
                mProfileDir = dir;
                mInGuestMode = null;
            }
        }
    }

    @RobocopTarget
    public String getName() {
        return mName;
    }

    public boolean isCustomProfile() {
        return CUSTOM_PROFILE.equals(mName);
    }

    @RobocopTarget
    public boolean inGuestMode() {
        if (mInGuestMode == null) {
            mInGuestMode = isGuestProfile(GeckoAppShell.getApplicationContext(),
                                          mName, mProfileDir);
        }
        return mInGuestMode;
    }

    /**
     * Return an Object that can be used with a synchronized statement to allow
     * exclusive access to the profile.
     */
    public Object getLock() {
        return this;
    }

    /**
     * Retrieves the directory backing the profile. This method acts
     * as a lazy initializer for the GeckoProfile instance.
     */
    @RobocopTarget
    public synchronized File getDir() {
        forceCreateLocked();
        return mProfileDir;
    }

    /**
     * Forces profile creation. Consider using {@link #getDir()} to initialize the profile instead - it is the
     * lazy initializer and, for our code reasoning abilities, we should initialize the profile in one place.
     */
    private void forceCreateLocked() {
        if (mProfileDir != null) {
            return;
        }

        try {
            // Check if a profile with this name already exists.
            try {
                mProfileDir = findProfileDir();
                Log.d(LOGTAG, "Found profile dir.");
            } catch (NoSuchProfileException noSuchProfile) {
                // If it doesn't exist, create it.
                mProfileDir = createProfileDir();
            }
        } catch (IOException ioe) {
            Log.e(LOGTAG, "Error getting profile dir", ioe);
        }
    }

    public File getFile(String aFile) {
        File f = getDir();
        if (f == null)
            return null;

        return new File(f, aFile);
    }

    /**
     * Retrieves the Gecko client ID from the filesystem. If the client ID does not exist, we attempt to migrate and
     * persist it from FHR and, if that fails, we attempt to create a new one ourselves.
     *
     * This method assumes the client ID is located in a file at a hard-coded path within the profile. The format of
     * this file is a JSONObject which at the bottom level contains a String -> String mapping containing the client ID.
     *
     * WARNING: the platform provides a JSM to retrieve the client ID [1] and this would be a
     * robust way to access it. However, we don't want to rely on Gecko running in order to get
     * the client ID so instead we access the file this module accesses directly. However, it's
     * possible the format of this file (and the access calls in the jsm) will change, leaving
     * this code to fail. There are tests in TestGeckoProfile to verify the file format but be
     * warned: THIS IS NOT FOOLPROOF.
     *
     * [1]: https://dxr.mozilla.org/mozilla-central/source/toolkit/modules/ClientID.jsm
     *
     * @throws IOException if the client ID could not be retrieved.
     */
    // Mimics ClientID.jsm – _doLoadClientID.
    @WorkerThread
    public String getClientId() throws IOException {
        try {
            return getValidClientIdFromDisk(CLIENT_ID_FILE_PATH);
        } catch (final IOException e) {
            // Avoid log spam: don't log the full Exception w/ the stack trace.
            Log.d(LOGTAG, "Could not get client ID - attempting to migrate ID from FHR: " + e.getLocalizedMessage());
        }

        String clientIdToWrite;
        try {
            clientIdToWrite = getValidClientIdFromDisk(FHR_CLIENT_ID_FILE_PATH);
        } catch (final IOException e) {
            // Avoid log spam: don't log the full Exception w/ the stack trace.
            Log.d(LOGTAG, "Could not migrate client ID from FHR – creating a new one: " + e.getLocalizedMessage());
            clientIdToWrite = generateNewClientId();
        }

        // There is a possibility Gecko is running and the Gecko telemetry implementation decided it's time to generate
        // the client ID, writing client ID underneath us. Since it's highly unlikely (e.g. we run in onStart before
        // Gecko is started), we don't handle that possibility besides writing the ID and then reading from the file
        // again (rather than just returning the value we generated before writing).
        //
        // In the event it does happen, any discrepancy will be resolved after a restart. In the mean time, both this
        // implementation and the Gecko implementation could upload documents with inconsistent IDs.
        //
        // In any case, if we get an exception, intentionally throw - there's nothing more to do here.
        persistClientId(clientIdToWrite);
        return getValidClientIdFromDisk(CLIENT_ID_FILE_PATH);
    }

    protected static String generateNewClientId() {
        return UUID.randomUUID().toString();
    }

    /**
     * @return a valid client ID
     * @throws IOException if a valid client ID could not be retrieved
     */
    @WorkerThread
    private String getValidClientIdFromDisk(final String filePath) throws IOException {
        final JSONObject obj = readJSONObjectFromFile(filePath);
        final String clientId = obj.optString(CLIENT_ID_JSON_ATTR);
        if (isClientIdValid(clientId)) {
            return clientId;
        }
        throw new IOException("Received client ID is invalid: " + clientId);
    }

    /**
     * Persists the given client ID to disk. This will overwrite any existing files.
     */
    @WorkerThread
    private void persistClientId(final String clientId) throws IOException {
        if (!ensureParentDirs(CLIENT_ID_FILE_PATH)) {
            throw new IOException("Could not create client ID parent directories");
        }

        final JSONObject obj = new JSONObject();
        try {
            obj.put(CLIENT_ID_JSON_ATTR, clientId);
        } catch (final JSONException e) {
            throw new IOException("Could not create client ID JSON object", e);
        }

        // ClientID.jsm overwrites the file to store the client ID so it's okay if we do it too.
        Log.d(LOGTAG, "Attempting to write new client ID");
        writeFile(CLIENT_ID_FILE_PATH, obj.toString()); // Logs errors within function: ideally we'd throw.
    }

    // From ClientID.jsm - isValidClientID.
    public static boolean isClientIdValid(final String clientId) {
        // We could use UUID.fromString but, for consistency, we take the implementation from ClientID.jsm.
        if (TextUtils.isEmpty(clientId)) {
            return false;
        }
        return clientId.matches("(?i:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})");
    }

    /**
     * Gets the profile creation date and persists it if it had to be generated.
     *
     * To get this value, we first look in times.json. If that could not be accessed, we
     * return the package's first install date. This is not a perfect solution because a
     * user may have large gap between install time and first use.
     *
     * A more correct algorithm could be the one performed by the JS code in ProfileAge.jsm
     * getOldestProfileTimestamp: walk the tree and return the oldest timestamp on the files
     * within the profile. However, since times.json will only not exist for the small
     * number of really old profiles, we're okay with the package install date compromise for
     * simplicity.
     *
     * @return the profile creation date in the format returned by {@link System#currentTimeMillis()}
     *         or -1 if the value could not be persisted.
     */
    @WorkerThread
    public long getAndPersistProfileCreationDate(final Context context) {
        try {
            return getProfileCreationDateFromTimesFile();
        } catch (final IOException e) {
            Log.d(LOGTAG, "Unable to retrieve profile creation date from times.json. Getting from system...");
            final long packageInstallMillis = org.mozilla.gecko.util.ContextUtils.getCurrentPackageInfo(context).firstInstallTime;
            try {
                persistProfileCreationDateToTimesFile(packageInstallMillis);
            } catch (final IOException ioEx) {
                // We return -1 to ensure the profileCreationDate
                // will either be an error (-1) or a consistent value.
                Log.w(LOGTAG, "Unable to persist profile creation date - returning -1");
                return -1;
            }

            return packageInstallMillis;
        }
    }

    @WorkerThread
    private long getProfileCreationDateFromTimesFile() throws IOException {
        final JSONObject obj = readJSONObjectFromFile(TIMES_PATH);
        try {
            return obj.getLong(PROFILE_CREATION_DATE_JSON_ATTR);
        } catch (final JSONException e) {
            // Don't log to avoid leaking data in JSONObject.
            throw new IOException("Profile creation does not exist in JSONObject");
        }
    }

    @WorkerThread
    private void persistProfileCreationDateToTimesFile(final long profileCreationMillis) throws IOException {
        final JSONObject obj = new JSONObject();
        try {
            obj.put(PROFILE_CREATION_DATE_JSON_ATTR, profileCreationMillis);
        } catch (final JSONException e) {
            // Don't log to avoid leaking data in JSONObject.
            throw new IOException("Unable to persist profile creation date to times file");
        }
        Log.d(LOGTAG, "Attempting to write new profile creation date");
        writeFile(TIMES_PATH, obj.toString()); // Ideally we'd throw here too.
    }

    /**
     * Updates the state of the old session data file.
     *
     * sessionstore.js should hold the current session, and sessionstore.old should
     * hold the previous session (where it is used to read the "tabs from last time").
     * If we're not restoring tabs automatically, sessionstore.js needs to be moved to
     * sessionstore.old, so we can display the correct "tabs from last time".
     * If we *are* restoring tabs, we need to delete outdated copies of sessionstore.old,
     * so we don't continue showing stale "tabs from last time" indefinitely.
     *
     * @param shouldRestore Pass true if we are automatically restoring last session's tabs.
     */
    public void updateSessionFile(boolean shouldRestore) {
        File sessionFilePrevious = getFile(SESSION_FILE_PREVIOUS);
        if (!shouldRestore) {
            File sessionFile = getFile(SESSION_FILE);
            if (sessionFile != null && sessionFile.exists()) {
                sessionFile.renameTo(sessionFilePrevious);
            }
        } else {
            if (sessionFilePrevious != null && sessionFilePrevious.exists() &&
                    System.currentTimeMillis() - sessionFilePrevious.lastModified() > MAX_PREVIOUS_FILE_AGE) {
                sessionFilePrevious.delete();
            }
        }
        synchronized (this) {
            mOldSessionDataProcessed = true;
            notifyAll();
        }
    }

    public void waitForOldSessionDataProcessing() {
        synchronized (this) {
            while (!mOldSessionDataProcessed) {
                try {
                    wait();
                } catch (final InterruptedException e) {
                    // Ignore and wait again.
                }
            }
        }
    }

    /**
     * Get the string from a session file.
     *
     * The session can either be read from sessionstore.js or sessionstore.bak.
     * In general, sessionstore.js holds the current session, and
     * sessionstore.bak holds a backup copy in case of interrupted writes.
     *
     * @param readBackup if true, the session is read from sessionstore.bak;
     *                   otherwise, the session is read from sessionstore.js
     *
     * @return the session string
     */
    public String readSessionFile(boolean readBackup) {
        return readSessionFile(readBackup ? SESSION_FILE_BACKUP : SESSION_FILE);
    }

    /**
     * Get the string from last session's session file.
     *
     * If we are not restoring tabs automatically, sessionstore.old will contain
     * the previous session.
     *
     * @return the session string
     */
    public String readPreviousSessionFile() {
        return readSessionFile(SESSION_FILE_PREVIOUS);
    }

    private String readSessionFile(String fileName) {
        File sessionFile = getFile(fileName);

        try {
            if (sessionFile != null && sessionFile.exists()) {
                return readFile(sessionFile);
            }
        } catch (IOException ioe) {
            Log.e(LOGTAG, "Unable to read session file", ioe);
        }
        return null;
    }

    /**
     * Checks whether the session store file exists.
     */
    public boolean sessionFileExists() {
        File sessionFile = getFile(SESSION_FILE);

        return sessionFile != null && sessionFile.exists();
    }

    /**
     * Ensures the parent director(y|ies) of the given filename exist by making them
     * if they don't already exist..
     *
     * @param filename The path to the file whose parents should be made directories
     * @return true if the parent directory exists, false otherwise
     */
    @WorkerThread
    protected boolean ensureParentDirs(final String filename) {
        final File file = new File(getDir(), filename);
        final File parentFile = file.getParentFile();
        return parentFile.mkdirs() || parentFile.isDirectory();
    }

    public void writeFile(final String filename, final String data) {
        File file = new File(getDir(), filename);
        BufferedWriter bufferedWriter = null;
        try {
            bufferedWriter = new BufferedWriter(new FileWriter(file, false));
            bufferedWriter.write(data);
        } catch (IOException e) {
            Log.e(LOGTAG, "Unable to write to file", e);
        } finally {
            try {
                if (bufferedWriter != null) {
                    bufferedWriter.close();
                }
            } catch (IOException e) {
                Log.e(LOGTAG, "Error closing writer while writing to file", e);
            }
        }
    }

    @WorkerThread
    public JSONObject readJSONObjectFromFile(final String filename) throws IOException {
        final String fileContents;
        try {
            fileContents = readFile(filename);
        } catch (final IOException e) {
            // Don't log exception to avoid leaking profile path.
            throw new IOException("Could not access given file to retrieve JSONObject");
        }

        try {
            return new JSONObject(fileContents);
        } catch (final JSONException e) {
            // Don't log exception to avoid leaking profile path.
            throw new IOException("Could not parse JSON to retrieve JSONObject");
        }
    }

    public JSONArray readJSONArrayFromFile(final String filename) {
        String fileContent;
        try {
            fileContent = readFile(filename);
        } catch (IOException expected) {
            return new JSONArray();
        }

        JSONArray jsonArray;
        try {
            jsonArray = new JSONArray(fileContent);
        } catch (JSONException e) {
            jsonArray = new JSONArray();
        }
        return jsonArray;
    }

    public String readFile(String filename) throws IOException {
        File dir = getDir();
        if (dir == null) {
            throw new IOException("No profile directory found");
        }
        File target = new File(dir, filename);
        return readFile(target);
    }

    private String readFile(File target) throws IOException {
        FileReader fr = new FileReader(target);
        try {
            StringBuilder sb = new StringBuilder();
            char[] buf = new char[8192];
            int read = fr.read(buf);
            while (read >= 0) {
                sb.append(buf, 0, read);
                read = fr.read(buf);
            }
            return sb.toString();
        } finally {
            fr.close();
        }
    }

    public boolean deleteFileFromProfileDir(String fileName) throws IllegalArgumentException {
        if (TextUtils.isEmpty(fileName)) {
            throw new IllegalArgumentException("Filename cannot be empty.");
        }
        File file = new File(getDir(), fileName);
        return file.delete();
    }

    private boolean remove() {
        try {
            synchronized (this) {
                if (mProfileDir != null && mProfileDir.exists()) {
                    FileUtils.delete(mProfileDir);
                }

                if (isCustomProfile()) {
                    // Custom profiles don't have profile.ini sections that we need to remove.
                    return true;
                }

                try {
                    // If findProfileDir() succeeds, it means the profile was created
                    // through forceCreate(), so we set mProfileDir to null to enable
                    // forceCreate() to create the profile again.
                    findProfileDir();
                    mProfileDir = null;

                } catch (final NoSuchProfileException e) {
                    // If findProfileDir() throws, it means the profile was not created
                    // through forceCreate(), and we have to preserve mProfileDir because
                    // it was given to us. In that case, there's nothing left to do here.
                    return true;
                }
            }

            final INIParser parser = GeckoProfileDirectories.getProfilesINI(mMozillaDir);
            final Hashtable<String, INISection> sections = parser.getSections();
            for (Enumeration<INISection> e = sections.elements(); e.hasMoreElements();) {
                final INISection section = e.nextElement();
                String name = section.getStringProperty("Name");

                if (name == null || !name.equals(mName)) {
                    continue;
                }

                if (section.getName().startsWith("Profile")) {
                    // ok, we have stupid Profile#-named things.  Rename backwards.
                    try {
                        int sectionNumber = Integer.parseInt(section.getName().substring("Profile".length()));
                        String curSection = "Profile" + sectionNumber;
                        String nextSection = "Profile" + (sectionNumber + 1);

                        sections.remove(curSection);

                        while (sections.containsKey(nextSection)) {
                            parser.renameSection(nextSection, curSection);
                            sectionNumber++;

                            curSection = nextSection;
                            nextSection = "Profile" + (sectionNumber + 1);
                        }
                    } catch (NumberFormatException nex) {
                        // uhm, malformed Profile thing; we can't do much.
                        Log.e(LOGTAG, "Malformed section name in profiles.ini: " + section.getName());
                        return false;
                    }
                } else {
                    // this really shouldn't be the case, but handle it anyway
                    parser.removeSection(mName);
                }

                break;
            }

            parser.write();
            return true;
        } catch (IOException ex) {
            Log.w(LOGTAG, "Failed to remove profile.", ex);
            return false;
        }
    }

    /**
     * @return the default profile name for this application, or
     *         {@link GeckoProfile#DEFAULT_PROFILE} if none could be found.
     *
     * @throws NoMozillaDirectoryException
     *             if the Mozilla directory did not exist and could not be
     *             created.
     */
    public static String getDefaultProfileName(final Context context) throws NoMozillaDirectoryException {
        // Have we read the default profile from the INI already?
        // Changing the default profile requires a restart, so we don't
        // need to worry about runtime changes.
        if (sDefaultProfileName != null) {
            return sDefaultProfileName;
        }

        final String profileName = GeckoProfileDirectories.findDefaultProfileName(context);
        if (profileName == null) {
            // Note that we don't persist this back to profiles.ini.
            sDefaultProfileName = DEFAULT_PROFILE;
            return DEFAULT_PROFILE;
        }

        sDefaultProfileName = profileName;
        return sDefaultProfileName;
    }

    private File findProfileDir() throws NoSuchProfileException {
        if (isCustomProfile()) {
            return mProfileDir;
        }
        return GeckoProfileDirectories.findProfileDir(mMozillaDir, mName);
    }

    @WorkerThread
    private File createProfileDir() throws IOException {
        if (isCustomProfile()) {
            // Custom profiles must already exist.
            return mProfileDir;
        }

        INIParser parser = GeckoProfileDirectories.getProfilesINI(mMozillaDir);

        // Salt the name of our requested profile
        String saltedName;
        File profileDir;
        do {
            saltedName = GeckoProfileDirectories.saltProfileName(mName);
            profileDir = new File(mMozillaDir, saltedName);
        } while (profileDir.exists());

        // Attempt to create the salted profile dir
        if (!profileDir.mkdirs()) {
            throw new IOException("Unable to create profile.");
        }
        Log.d(LOGTAG, "Created new profile dir.");

        // Now update profiles.ini
        // If this is the first time its created, we also add a General section
        // look for the first profile number that isn't taken yet
        int profileNum = 0;
        boolean isDefaultSet = false;
        INISection profileSection;
        while ((profileSection = parser.getSection("Profile" + profileNum)) != null) {
            profileNum++;
            if (profileSection.getProperty("Default") != null) {
                isDefaultSet = true;
            }
        }

        profileSection = new INISection("Profile" + profileNum);
        profileSection.setProperty("Name", mName);
        profileSection.setProperty("IsRelative", 1);
        profileSection.setProperty("Path", saltedName);

        if (parser.getSection("General") == null) {
            INISection generalSection = new INISection("General");
            generalSection.setProperty("StartWithLastProfile", 1);
            parser.addSection(generalSection);
        }

        if (!isDefaultSet) {
            // only set as default if this is the first profile we're creating
            profileSection.setProperty("Default", 1);
        }

        parser.addSection(profileSection);
        parser.write();

        enqueueInitialization(profileDir);

        // Write out profile creation time, mirroring the logic in nsToolkitProfileService.
        try {
            FileOutputStream stream = new FileOutputStream(profileDir.getAbsolutePath() + File.separator + TIMES_PATH);
            OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8"));
            try {
                writer.append("{\"created\": " + System.currentTimeMillis() + "}\n");
            } finally {
                writer.close();
            }
        } catch (Exception e) {
            // Best-effort.
            Log.w(LOGTAG, "Couldn't write " + TIMES_PATH, e);
        }

        // Create the client ID file before Gecko starts (we assume this method
        // is called before Gecko starts). If we let Gecko start, the JS telemetry
        // code may try to write to the file at the same time Java does.
        persistClientId(generateNewClientId());

        return profileDir;
    }

    /**
     * This method is called once, immediately before creation of the profile
     * directory completes.
     *
     * It queues up work to be done in the background to prepare the profile,
     * such as adding default bookmarks.
     *
     * This is public for use *from tests only*!
     */
    @RobocopTarget
    public void enqueueInitialization(final File profileDir) {
        Log.i(LOGTAG, "Enqueuing profile init.");

        final Bundle message = new Bundle(2);
        message.putCharSequence("name", getName());
        message.putCharSequence("path", profileDir.getAbsolutePath());
        EventDispatcher.getInstance().dispatch("Profile:Create", message);
    }
}