diff --git a/bolts-tasks/src/main/java/com/parse/boltsinternal/CancellationTokenSource.java b/bolts-tasks/src/main/java/com/parse/boltsinternal/CancellationTokenSource.java
index 2ca4a19f9..210eae9cf 100644
--- a/bolts-tasks/src/main/java/com/parse/boltsinternal/CancellationTokenSource.java
+++ b/bolts-tasks/src/main/java/com/parse/boltsinternal/CancellationTokenSource.java
@@ -46,7 +46,9 @@ public boolean isCancellationRequested() {
         }
     }
 
-    /** @return the token that can be passed to asynchronous method to control cancellation. */
+    /**
+     * @return the token that can be passed to asynchronous method to control cancellation.
+     */
     public CancellationToken getToken() {
         synchronized (lock) {
             throwIfClosed();
diff --git a/bolts-tasks/src/main/java/com/parse/boltsinternal/Task.java b/bolts-tasks/src/main/java/com/parse/boltsinternal/Task.java
index 8e73588b2..34da3dcb3 100644
--- a/bolts-tasks/src/main/java/com/parse/boltsinternal/Task.java
+++ b/bolts-tasks/src/main/java/com/parse/boltsinternal/Task.java
@@ -541,28 +541,36 @@ public boolean isCompleted() {
         }
     }
 
-    /** @return {@code true} if the task was cancelled, {@code false} otherwise. */
+    /**
+     * @return {@code true} if the task was cancelled, {@code false} otherwise.
+     */
     public boolean isCancelled() {
         synchronized (lock) {
             return cancelled;
         }
     }
 
-    /** @return {@code true} if the task has an error, {@code false} otherwise. */
+    /**
+     * @return {@code true} if the task has an error, {@code false} otherwise.
+     */
     public boolean isFaulted() {
         synchronized (lock) {
             return getError() != null;
         }
     }
 
-    /** @return The result of the task, if set. {@code null} otherwise. */
+    /**
+     * @return The result of the task, if set. {@code null} otherwise.
+     */
     public TResult getResult() {
         synchronized (lock) {
             return result;
         }
     }
 
-    /** @return The error for the task, if set. {@code null} otherwise. */
+    /**
+     * @return The error for the task, if set. {@code null} otherwise.
+     */
     public Exception getError() {
         synchronized (lock) {
             if (error != null) {
diff --git a/bolts-tasks/src/main/java/com/parse/boltsinternal/TaskCompletionSource.java b/bolts-tasks/src/main/java/com/parse/boltsinternal/TaskCompletionSource.java
index 4406cefbd..4513b76d6 100644
--- a/bolts-tasks/src/main/java/com/parse/boltsinternal/TaskCompletionSource.java
+++ b/bolts-tasks/src/main/java/com/parse/boltsinternal/TaskCompletionSource.java
@@ -24,7 +24,9 @@ public TaskCompletionSource() {
         task = new Task<>();
     }
 
-    /** @return the Task associated with this TaskCompletionSource. */
+    /**
+     * @return the Task associated with this TaskCompletionSource.
+     */
     public Task<TResult> getTask() {
         return task;
     }
diff --git a/build.gradle b/build.gradle
index 04721faf7..7f8c9bcec 100644
--- a/build.gradle
+++ b/build.gradle
@@ -11,7 +11,7 @@ buildscript {
         classpath "org.jacoco:org.jacoco.core:$jacocoVersion"
         classpath "com.dicedmelon.gradle:jacoco-android:0.1.5"
         classpath "io.freefair.gradle:android-gradle-plugins:4.2.0-m1"
-        classpath "com.diffplug.spotless:spotless-plugin-gradle:5.17.1"
+        classpath "com.diffplug.spotless:spotless-plugin-gradle:6.7.1"
     }
 }
 
diff --git a/parse/build.gradle b/parse/build.gradle
index 9253396d9..0494a28f6 100644
--- a/parse/build.gradle
+++ b/parse/build.gradle
@@ -1,4 +1,5 @@
 apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
 apply plugin: "maven-publish"
 apply plugin: "io.freefair.android-javadoc-jar"
 apply plugin: "io.freefair.android-sources-jar"
@@ -50,6 +51,7 @@ dependencies {
     api "androidx.core:core:1.8.0"
     api "com.squareup.okhttp3:okhttp:$okhttpVersion"
     api project(':bolts-tasks')
+    implementation "androidx.security:security-crypto:1.1.0-alpha03"
 
     testImplementation "org.junit.jupiter:junit-jupiter:$rootProject.ext.jupiterVersion"
     testImplementation "org.skyscreamer:jsonassert:1.5.0"
diff --git a/parse/src/main/java/com/parse/EncryptedFileObjectStore.java b/parse/src/main/java/com/parse/EncryptedFileObjectStore.java
new file mode 100644
index 000000000..59caa1a3e
--- /dev/null
+++ b/parse/src/main/java/com/parse/EncryptedFileObjectStore.java
@@ -0,0 +1,135 @@
+package com.parse;
+
+import android.content.Context;
+import androidx.security.crypto.EncryptedFile;
+import androidx.security.crypto.MasterKey;
+import com.parse.boltsinternal.Task;
+import java.io.File;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.concurrent.Callable;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * a file based {@link ParseObjectStore} using Jetpack's {@link EncryptedFile} class to protect
+ * files from a malicious copy.
+ */
+class EncryptedFileObjectStore<T extends ParseObject> implements ParseObjectStore<T> {
+
+    private final String className;
+    private final File file;
+    private final EncryptedFile encryptedFile;
+    private final ParseObjectCurrentCoder coder;
+
+    public EncryptedFileObjectStore(Class<T> clazz, File file, ParseObjectCurrentCoder coder) {
+        this(getSubclassingController().getClassName(clazz), file, coder);
+    }
+
+    public EncryptedFileObjectStore(String className, File file, ParseObjectCurrentCoder coder) {
+        this.className = className;
+        this.file = file;
+        this.coder = coder;
+        Context context = ParsePlugins.get().applicationContext();
+        try {
+            encryptedFile =
+                    new EncryptedFile.Builder(
+                                    context,
+                                    file,
+                                    new MasterKey.Builder(context)
+                                            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+                                            .build(),
+                                    EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB)
+                            .build();
+        } catch (GeneralSecurityException | IOException e) {
+            throw new RuntimeException(e.getMessage());
+        }
+    }
+
+    private static ParseObjectSubclassingController getSubclassingController() {
+        return ParseCorePlugins.getInstance().getSubclassingController();
+    }
+
+    /**
+     * Saves the {@code ParseObject} to the a file on disk as JSON in /2/ format.
+     *
+     * @param current ParseObject which needs to be saved to disk.
+     * @throws IOException thrown if an error occurred during writing of the file
+     * @throws GeneralSecurityException thrown if there is an error with encryption keys or during
+     *     the encryption of the file
+     */
+    private void saveToDisk(ParseObject current) throws IOException, GeneralSecurityException {
+        JSONObject json = coder.encode(current.getState(), null, PointerEncoder.get());
+        ParseFileUtils.writeJSONObjectToFile(encryptedFile, json);
+    }
+
+    /**
+     * Retrieves a {@code ParseObject} from a file on disk in /2/ format.
+     *
+     * @return The {@code ParseObject} that was retrieved. If the file wasn't found, or the contents
+     *     of the file is an invalid {@code ParseObject}, returns {@code null}.
+     * @throws GeneralSecurityException thrown if there is an error with encryption keys or during
+     *     the encryption of the file
+     * @throws JSONException thrown if an error occurred during the decoding process of the
+     *     ParseObject to a JSONObject
+     * @throws IOException thrown if an error occurred during writing of the file
+     */
+    private T getFromDisk() throws GeneralSecurityException, JSONException, IOException {
+        return ParseObject.from(
+                coder.decode(
+                                ParseObject.State.newBuilder(className),
+                                ParseFileUtils.readFileToJSONObject(encryptedFile),
+                                ParseDecoder.get())
+                        .isComplete(true)
+                        .build());
+    }
+
+    @Override
+    public Task<T> getAsync() {
+        return Task.call(
+                new Callable<T>() {
+                    @Override
+                    public T call() throws Exception {
+                        if (!file.exists()) return null;
+                        try {
+                            return getFromDisk();
+                        } catch (GeneralSecurityException e) {
+                            throw new RuntimeException(e.getMessage());
+                        }
+                    }
+                },
+                ParseExecutors.io());
+    }
+
+    @Override
+    public Task<Void> setAsync(T object) {
+        return Task.call(
+                () -> {
+                    if (file.exists() && !ParseFileUtils.deleteQuietly(file))
+                        throw new RuntimeException("Unable to delete");
+                    try {
+                        saveToDisk(object);
+                    } catch (GeneralSecurityException e) {
+                        throw new RuntimeException(e.getMessage());
+                    }
+                    return null;
+                },
+                ParseExecutors.io());
+    }
+
+    @Override
+    public Task<Boolean> existsAsync() {
+        return Task.call(file::exists, ParseExecutors.io());
+    }
+
+    @Override
+    public Task<Void> deleteAsync() {
+        return Task.call(
+                () -> {
+                    if (file.exists() && !ParseFileUtils.deleteQuietly(file))
+                        throw new RuntimeException("Unable to delete");
+                    return null;
+                },
+                ParseExecutors.io());
+    }
+}
diff --git a/parse/src/main/java/com/parse/ManifestInfo.java b/parse/src/main/java/com/parse/ManifestInfo.java
index 31b221976..276fcd14b 100644
--- a/parse/src/main/java/com/parse/ManifestInfo.java
+++ b/parse/src/main/java/com/parse/ManifestInfo.java
@@ -150,7 +150,9 @@ private static ApplicationInfo getApplicationInfo(Context context, int flags) {
         }
     }
 
-    /** @return A {@link Bundle} if meta-data is specified in AndroidManifest, otherwise null. */
+    /**
+     * @return A {@link Bundle} if meta-data is specified in AndroidManifest, otherwise null.
+     */
     public static Bundle getApplicationMetadata(Context context) {
         ApplicationInfo info = getApplicationInfo(context, PackageManager.GET_META_DATA);
         if (info != null) {
diff --git a/parse/src/main/java/com/parse/Parse.java b/parse/src/main/java/com/parse/Parse.java
index c3ad5c1ee..66fa2502a 100644
--- a/parse/src/main/java/com/parse/Parse.java
+++ b/parse/src/main/java/com/parse/Parse.java
@@ -288,7 +288,9 @@ public static void destroy() {
         allowCustomObjectId = false;
     }
 
-    /** @return {@code True} if {@link #initialize} has been called, otherwise {@code false}. */
+    /**
+     * @return {@code True} if {@link #initialize} has been called, otherwise {@code false}.
+     */
     static boolean isInitialized() {
         return ParsePlugins.get() != null;
     }
diff --git a/parse/src/main/java/com/parse/ParseClassName.java b/parse/src/main/java/com/parse/ParseClassName.java
index d2f4a3562..ab5b436a2 100644
--- a/parse/src/main/java/com/parse/ParseClassName.java
+++ b/parse/src/main/java/com/parse/ParseClassName.java
@@ -21,6 +21,8 @@
 @Inherited
 @Documented
 public @interface ParseClassName {
-    /** @return The Parse class name associated with the ParseObject subclass. */
+    /**
+     * @return The Parse class name associated with the ParseObject subclass.
+     */
     String value();
 }
diff --git a/parse/src/main/java/com/parse/ParseCorePlugins.java b/parse/src/main/java/com/parse/ParseCorePlugins.java
index 01d5ed54b..197b3c2af 100644
--- a/parse/src/main/java/com/parse/ParseCorePlugins.java
+++ b/parse/src/main/java/com/parse/ParseCorePlugins.java
@@ -135,7 +135,12 @@ public ParseCurrentUserController getCurrentUserController() {
                     Parse.isLocalDatastoreEnabled()
                             ? new OfflineObjectStore<>(ParseUser.class, PIN_CURRENT_USER, fileStore)
                             : fileStore;
-            ParseCurrentUserController controller = new CachedCurrentUserController(store);
+            EncryptedFileObjectStore<ParseUser> encryptedFileObjectStore =
+                    new EncryptedFileObjectStore<>(
+                            ParseUser.class, file, ParseUserCurrentCoder.get());
+            ParseObjectStoreMigrator<ParseUser> storeMigrator =
+                    new ParseObjectStoreMigrator<>(encryptedFileObjectStore, store);
+            ParseCurrentUserController controller = new CachedCurrentUserController(storeMigrator);
             currentUserController.compareAndSet(null, controller);
         }
         return currentUserController.get();
diff --git a/parse/src/main/java/com/parse/ParseFileUtils.java b/parse/src/main/java/com/parse/ParseFileUtils.java
index c48f7b517..d0c0d1732 100644
--- a/parse/src/main/java/com/parse/ParseFileUtils.java
+++ b/parse/src/main/java/com/parse/ParseFileUtils.java
@@ -18,6 +18,7 @@
 
 import android.net.Uri;
 import androidx.annotation.NonNull;
+import androidx.security.crypto.EncryptedFile;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
@@ -27,6 +28,7 @@
 import java.io.OutputStream;
 import java.nio.channels.FileChannel;
 import java.nio.charset.Charset;
+import java.security.GeneralSecurityException;
 import java.util.List;
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -63,6 +65,25 @@ public static byte[] readFileToByteArray(File file) throws IOException {
 
     // -----------------------------------------------------------------------
 
+    /**
+     * Reads the contents of an encrypted file into a byte array. The file is always closed.
+     *
+     * @param file the encrypted file to read, must not be <code>null</code>
+     * @return the file contents, never <code>null</code>
+     * @throws IOException in case of an I/O error
+     * @throws GeneralSecurityException in case of an encryption related error
+     */
+    public static byte[] readFileToByteArray(EncryptedFile file)
+            throws IOException, GeneralSecurityException {
+        InputStream in = null;
+        try {
+            in = file.openFileInput();
+            return ParseIOUtils.toByteArray(in);
+        } finally {
+            ParseIOUtils.closeQuietly(in);
+        }
+    }
+
     /**
      * Opens a {@link FileInputStream} for the specified file, providing better error messages than
      * simply calling <code>new FileInputStream(file)</code>.
@@ -116,6 +137,25 @@ public static void writeByteArrayToFile(File file, byte[] data) throws IOExcepti
         }
     }
 
+    /**
+     * Writes a byte array to an encrypted file, will not create the file if it does not exist.
+     *
+     * @param file the file to write to
+     * @param data the content to write to the file
+     * @throws IOException in case of an I/O error
+     * @throws GeneralSecurityException in case of an encryption related error
+     */
+    public static void writeByteArrayToFile(EncryptedFile file, byte[] data)
+            throws IOException, GeneralSecurityException {
+        OutputStream out = null;
+        try {
+            out = file.openFileOutput();
+            out.write(data);
+        } finally {
+            ParseIOUtils.closeQuietly(out);
+        }
+    }
+
     /**
      * Writes a content uri to a file creating the file if it does not exist.
      *
@@ -549,6 +589,34 @@ public static boolean isSymlink(final File file) throws IOException {
         return !fileInCanonicalDir.getCanonicalFile().equals(fileInCanonicalDir.getAbsoluteFile());
     }
 
+    /**
+     * @param file the encrypted file to read
+     * @param encoding the file encoding used when written to disk
+     * @return Reads the contents of an encrypted file into a {@link String}. The file is always
+     *     closed.
+     * @throws IOException thrown if an error occurred during writing of the file
+     * @throws GeneralSecurityException thrown if there is an error with encryption keys or during
+     *     the encryption of the file
+     */
+    public static String readFileToString(EncryptedFile file, Charset encoding)
+            throws IOException, GeneralSecurityException {
+        return new String(readFileToByteArray(file), encoding);
+    }
+
+    /**
+     * @param file the encrypted file to read
+     * @param encoding the file encoding used when written to disk
+     * @return Reads the contents of an encrypted file into a {@link String}. The file is always
+     *     closed.
+     * @throws IOException thrown if an error occurred during writing of the file
+     * @throws GeneralSecurityException thrown if there is an error with encryption keys or during
+     *     the encryption of the file
+     */
+    public static String readFileToString(EncryptedFile file, String encoding)
+            throws IOException, GeneralSecurityException {
+        return readFileToString(file, Charset.forName(encoding));
+    }
+
     // region String
 
     public static String readFileToString(File file, Charset encoding) throws IOException {
@@ -569,6 +637,38 @@ public static void writeStringToFile(File file, String string, String encoding)
         writeStringToFile(file, string, Charset.forName(encoding));
     }
 
+    /**
+     * Writes a {@link JSONObject} to an encrypted file, will throw an error if the file already
+     * exists.
+     *
+     * @param file the encrypted file to use for writing.
+     * @param string the text to write.
+     * @param encoding the encoding used for the text written.
+     * @throws IOException thrown if an error occurred during writing of the file
+     * @throws GeneralSecurityException thrown if there is an error with encryption keys or during
+     *     the encryption of the file
+     */
+    public static void writeStringToFile(EncryptedFile file, String string, Charset encoding)
+            throws IOException, GeneralSecurityException {
+        writeByteArrayToFile(file, string.getBytes(encoding));
+    }
+
+    /**
+     * Writes a {@link JSONObject} to an encrypted file, will throw an error if the file already
+     * exists.
+     *
+     * @param file the encrypted file to use for writing.
+     * @param string the text to write.
+     * @param encoding the encoding used for the text written.
+     * @throws IOException thrown if an error occurred during writing of the file
+     * @throws GeneralSecurityException thrown if there is an error with encryption keys or during
+     *     the encryption of the file
+     */
+    public static void writeStringToFile(EncryptedFile file, String string, String encoding)
+            throws IOException, GeneralSecurityException {
+        writeStringToFile(file, string, Charset.forName(encoding));
+    }
+
     // endregion
 
     // region JSONObject
@@ -584,5 +684,23 @@ public static void writeJSONObjectToFile(File file, JSONObject json) throws IOEx
         ParseFileUtils.writeByteArrayToFile(file, json.toString().getBytes("UTF-8"));
     }
 
+    /**
+     * Reads the contents of an encrypted file into a {@link JSONObject}. The file is always closed.
+     */
+    public static JSONObject readFileToJSONObject(EncryptedFile file)
+            throws IOException, JSONException, GeneralSecurityException {
+        String content = readFileToString(file, "UTF-8");
+        return new JSONObject(content);
+    }
+
+    /**
+     * Writes a {@link JSONObject} to an encrypted file, will throw an error if the file already
+     * exists.
+     */
+    public static void writeJSONObjectToFile(EncryptedFile file, JSONObject json)
+            throws IOException, GeneralSecurityException {
+        ParseFileUtils.writeByteArrayToFile(file, json.toString().getBytes("UTF-8"));
+    }
+
     // endregion
 }
diff --git a/parse/src/main/java/com/parse/ParseObjectStoreMigrator.java b/parse/src/main/java/com/parse/ParseObjectStoreMigrator.java
new file mode 100644
index 000000000..13218d100
--- /dev/null
+++ b/parse/src/main/java/com/parse/ParseObjectStoreMigrator.java
@@ -0,0 +1,82 @@
+package com.parse;
+
+import com.parse.boltsinternal.Continuation;
+import com.parse.boltsinternal.Task;
+import java.util.Arrays;
+
+/** Use this utility class to migrate from one {@link ParseObjectStore} to another */
+class ParseObjectStoreMigrator<T extends ParseObject> implements ParseObjectStore<T> {
+
+    private final ParseObjectStore<T> store;
+    private final ParseObjectStore<T> legacy;
+
+    /**
+     * @param store the new {@link ParseObjectStore} to migrate to
+     * @param legacy the old {@link ParseObjectStore} to migrate from
+     */
+    public ParseObjectStoreMigrator(ParseObjectStore<T> store, ParseObjectStore<T> legacy) {
+        this.store = store;
+        this.legacy = legacy;
+    }
+
+    @Override
+    public Task<T> getAsync() {
+        return store.getAsync()
+                .continueWithTask(
+                        new Continuation<T, Task<T>>() {
+                            @Override
+                            public Task<T> then(Task<T> task) throws Exception {
+                                if (task.getResult() != null) return task;
+                                return legacy.getAsync()
+                                        .continueWithTask(
+                                                new Continuation<T, Task<T>>() {
+                                                    @Override
+                                                    public Task<T> then(Task<T> task)
+                                                            throws Exception {
+                                                        T object = task.getResult();
+                                                        if (object == null) return task;
+                                                        return legacy.deleteAsync()
+                                                                .continueWith(
+                                                                        task1 ->
+                                                                                ParseTaskUtils.wait(
+                                                                                        store
+                                                                                                .setAsync(
+                                                                                                        object)))
+                                                                .onSuccess(task1 -> object);
+                                                    }
+                                                });
+                            }
+                        });
+    }
+
+    @Override
+    public Task<Void> setAsync(T object) {
+        return store.setAsync(object);
+    }
+
+    @Override
+    public Task<Boolean> existsAsync() {
+        return store.existsAsync()
+                .continueWithTask(
+                        new Continuation<Boolean, Task<Boolean>>() {
+                            @Override
+                            public Task<Boolean> then(Task<Boolean> task) throws Exception {
+                                if (task.getResult()) return Task.forResult(true);
+                                return legacy.existsAsync();
+                            }
+                        });
+    }
+
+    @Override
+    public Task<Void> deleteAsync() {
+        Task<Void> storeTask = store.deleteAsync();
+        return Task.whenAll(Arrays.asList(legacy.deleteAsync(), storeTask))
+                .continueWithTask(
+                        new Continuation<Void, Task<Void>>() {
+                            @Override
+                            public Task<Void> then(Task<Void> task1) throws Exception {
+                                return storeTask;
+                            }
+                        });
+    }
+}
diff --git a/parse/src/main/java/com/parse/ParseQuery.java b/parse/src/main/java/com/parse/ParseQuery.java
index 132ad4e39..67c44b3ef 100644
--- a/parse/src/main/java/com/parse/ParseQuery.java
+++ b/parse/src/main/java/com/parse/ParseQuery.java
@@ -282,7 +282,9 @@ public T getFirst() throws ParseException {
         return ParseTaskUtils.wait(getFirstInBackground());
     }
 
-    /** @return the caching policy. */
+    /**
+     * @return the caching policy.
+     */
     public CachePolicy getCachePolicy() {
         return builder.getCachePolicy();
     }
diff --git a/parse/src/main/java/com/parse/ParseSession.java b/parse/src/main/java/com/parse/ParseSession.java
index a2d5b2e4d..a656966d0 100644
--- a/parse/src/main/java/com/parse/ParseSession.java
+++ b/parse/src/main/java/com/parse/ParseSession.java
@@ -122,7 +122,9 @@ public static ParseQuery<ParseSession> getQuery() {
         return !READ_ONLY_KEYS.contains(key);
     }
 
-    /** @return the session token for a user, if they are logged in. */
+    /**
+     * @return the session token for a user, if they are logged in.
+     */
     public String getSessionToken() {
         return getString(KEY_SESSION_TOKEN);
     }
diff --git a/parse/src/main/java/com/parse/ParseUser.java b/parse/src/main/java/com/parse/ParseUser.java
index 6fdf91845..c52c34862 100644
--- a/parse/src/main/java/com/parse/ParseUser.java
+++ b/parse/src/main/java/com/parse/ParseUser.java
@@ -717,7 +717,9 @@ public void remove(@NonNull String key) {
         }
     }
 
-    /** @return the session token for a user, if they are logged in. */
+    /**
+     * @return the session token for a user, if they are logged in.
+     */
     public String getSessionToken() {
         return getState().sessionToken();
     }
diff --git a/parse/src/test/java/com/parse/AlgorithmParameterSpec.kt b/parse/src/test/java/com/parse/AlgorithmParameterSpec.kt
new file mode 100644
index 000000000..7e97ee6fa
--- /dev/null
+++ b/parse/src/test/java/com/parse/AlgorithmParameterSpec.kt
@@ -0,0 +1,6 @@
+package com.parse
+
+import java.security.spec.AlgorithmParameterSpec
+
+internal val AlgorithmParameterSpec.keystoreAlias: String
+    get() = this::class.java.getDeclaredMethod("getKeystoreAlias").invoke(this) as String
diff --git a/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt b/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt
new file mode 100644
index 000000000..8087b2117
--- /dev/null
+++ b/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt
@@ -0,0 +1,177 @@
+package com.parse
+
+/*
+ * Copyright 2020 Appmattus Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.io.InputStream
+import java.io.OutputStream
+import java.security.Key
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.KeyPairGeneratorSpi
+import java.security.KeyStore
+import java.security.KeyStoreSpi
+import java.security.Provider
+import java.security.SecureRandom
+import java.security.cert.Certificate
+import java.security.spec.AlgorithmParameterSpec
+import java.util.Collections
+import java.util.Date
+import java.util.Enumeration
+import javax.crypto.KeyGenerator
+import javax.crypto.KeyGeneratorSpi
+import javax.crypto.SecretKey
+
+class AndroidKeyStoreProvider : Provider("AndroidKeyStore", 1.0, "") {
+    init {
+        put("KeyStore.AndroidKeyStore", AndroidKeyStore::class.java.name)
+        put("KeyGenerator.AES", AesKeyGenerator::class.java.name)
+        put("KeyGenerator.HmacSHA256", HmacSHA256KeyGenerator::class.java.name)
+        put("KeyPairGenerator.RSA", RsaKeyPairGenerator::class.java.name)
+    }
+
+    @Suppress("TooManyFunctions")
+    class AndroidKeyStore : KeyStoreSpi() {
+        override fun engineIsKeyEntry(alias: String?): Boolean = wrapped.isKeyEntry(alias)
+
+        override fun engineIsCertificateEntry(alias: String?): Boolean =
+            wrapped.isCertificateEntry(alias)
+
+        override fun engineGetCertificate(alias: String?): Certificate =
+            wrapped.getCertificate(alias)
+
+        override fun engineGetCreationDate(alias: String?): Date = wrapped.getCreationDate(alias)
+
+        override fun engineDeleteEntry(alias: String?) {
+            storedKeys.remove(alias)
+        }
+
+        override fun engineSetKeyEntry(
+            alias: String?,
+            key: Key?,
+            password: CharArray?,
+            chain: Array<out Certificate>?,
+        ) =
+            wrapped.setKeyEntry(alias, key, password, chain)
+
+        override fun engineSetKeyEntry(
+            alias: String?,
+            key: ByteArray?,
+            chain: Array<out Certificate>?,
+        ) = wrapped.setKeyEntry(alias, key, chain)
+
+        override fun engineStore(stream: OutputStream?, password: CharArray?) =
+            wrapped.store(stream, password)
+
+        override fun engineSize(): Int = wrapped.size()
+
+        override fun engineAliases(): Enumeration<String> = Collections.enumeration(storedKeys.keys)
+
+        override fun engineContainsAlias(alias: String?): Boolean = storedKeys.containsKey(alias)
+
+        override fun engineLoad(stream: InputStream?, password: CharArray?) =
+            wrapped.load(stream, password)
+
+        override fun engineGetCertificateChain(alias: String?): Array<Certificate>? =
+            wrapped.getCertificateChain(alias)
+
+        override fun engineSetCertificateEntry(alias: String?, cert: Certificate?) =
+            wrapped.setCertificateEntry(alias, cert)
+
+        override fun engineGetCertificateAlias(cert: Certificate?): String? =
+            wrapped.getCertificateAlias(cert)
+
+        override fun engineGetKey(alias: String?, password: CharArray?): Key? =
+            (storedKeys[alias] as? KeyStore.SecretKeyEntry)?.secretKey
+
+        override fun engineGetEntry(
+            p0: String,
+            p1: KeyStore.ProtectionParameter?,
+        ): KeyStore.Entry? = storedKeys[p0]
+
+        override fun engineSetEntry(
+            p0: String,
+            p1: KeyStore.Entry,
+            p2: KeyStore.ProtectionParameter?,
+        ) {
+            storedKeys[p0] = p1
+        }
+
+        override fun engineLoad(p0: KeyStore.LoadStoreParameter?) = wrapped.load(p0)
+
+        override fun engineStore(p0: KeyStore.LoadStoreParameter?) = wrapped.store(p0)
+
+        override fun engineEntryInstanceOf(p0: String?, p1: Class<out KeyStore.Entry>?) =
+            wrapped.entryInstanceOf(p0, p1)
+
+        companion object {
+            private val wrapped = KeyStore.getInstance("BKS", "BC")
+            internal val storedKeys = mutableMapOf<String, KeyStore.Entry>()
+        }
+    }
+
+    class AesKeyGenerator : KeyGeneratorSpi() {
+        private val wrapped = KeyGenerator.getInstance("AES", "BC")
+        private var lastSpec: AlgorithmParameterSpec? = null
+
+        override fun engineInit(random: SecureRandom?) = wrapped.init(random)
+
+        override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) =
+            wrapped.init(random).also {
+                lastSpec = params
+            }
+
+        override fun engineInit(keysize: Int, random: SecureRandom?) = wrapped.init(keysize, random)
+
+        override fun engineGenerateKey(): SecretKey = wrapped.generateKey().also {
+            AndroidKeyStore.storedKeys[lastSpec!!.keystoreAlias] = KeyStore.SecretKeyEntry(it)
+        }
+    }
+
+    class HmacSHA256KeyGenerator : KeyGeneratorSpi() {
+        private val wrapped = KeyGenerator.getInstance("HmacSHA256", "BC")
+        private var lastSpec: AlgorithmParameterSpec? = null
+
+        override fun engineInit(random: SecureRandom?) = wrapped.init(random)
+        override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) =
+            wrapped.init(random).also {
+                lastSpec = params
+            }
+
+        override fun engineInit(keysize: Int, random: SecureRandom?) = Unit
+        override fun engineGenerateKey(): SecretKey = wrapped.generateKey().also {
+            AndroidKeyStore.storedKeys[lastSpec!!.keystoreAlias] = KeyStore.SecretKeyEntry(it)
+        }
+    }
+
+    class RsaKeyPairGenerator : KeyPairGeneratorSpi() {
+        private val wrapped = KeyPairGenerator.getInstance("RSA", "BC")
+
+        private var lastSpec: AlgorithmParameterSpec? = null
+
+        // {@link KeyPair#toCertificate()} is used for generating JcaX509 certificates using org.bouncycastle library which might not be required now, but can be implemented when needed.
+        override fun generateKeyPair(): KeyPair = wrapped.generateKeyPair().also { keyPair ->
+            null
+//            AndroidKeyStore.storedKeys[lastSpec!!.keystoreAlias] = KeyStore.PrivateKeyEntry(keyPair.private, arrayOf(keyPair.toCertificate()))
+        }
+
+        override fun initialize(p0: Int, p1: SecureRandom?) = Unit
+
+        override fun initialize(p0: AlgorithmParameterSpec?, p1: SecureRandom?) {
+            lastSpec = p0
+        }
+    }
+}
diff --git a/parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt b/parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt
new file mode 100644
index 000000000..2f98e8742
--- /dev/null
+++ b/parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt
@@ -0,0 +1,64 @@
+package com.parse
+
+/*
+ * Copyright 2020 Appmattus Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.annotation.SuppressLint
+import java.security.AlgorithmParameters
+import java.security.Key
+import java.security.Provider
+import java.security.SecureRandom
+import java.security.spec.AlgorithmParameterSpec
+import javax.crypto.Cipher
+import javax.crypto.CipherSpi
+
+class AndroidOpenSSLProvider : Provider("AndroidOpenSSL", 1.0, "") {
+    init {
+        put("Cipher.RSA/ECB/PKCS1Padding", RsaCipher::class.java.name)
+    }
+
+    @Suppress("TooManyFunctions")
+    class RsaCipher : CipherSpi() {
+        @SuppressLint("GetInstance")
+        private val wrapped = Cipher.getInstance("RSA/ECB/PKCS1Padding", "BC")
+
+        override fun engineSetMode(p0: String?) = Unit
+
+        override fun engineInit(p0: Int, p1: Key?, p2: SecureRandom?) = wrapped.init(p0, p1, p2)
+
+        override fun engineInit(p0: Int, p1: Key?, p2: AlgorithmParameterSpec?, p3: SecureRandom?) = wrapped.init(p0, p1, p2, p3)
+
+        override fun engineInit(p0: Int, p1: Key?, p2: AlgorithmParameters?, p3: SecureRandom?) = wrapped.init(p0, p1, p2, p3)
+
+        override fun engineGetIV(): ByteArray = wrapped.iv
+
+        override fun engineDoFinal(p0: ByteArray?, p1: Int, p2: Int): ByteArray = wrapped.doFinal(p0, p1, p2)
+
+        override fun engineDoFinal(p0: ByteArray?, p1: Int, p2: Int, p3: ByteArray?, p4: Int) = wrapped.doFinal(p0, p1, p2, p3, p4)
+
+        override fun engineSetPadding(p0: String?) = Unit
+
+        override fun engineGetParameters(): AlgorithmParameters = wrapped.parameters
+
+        override fun engineUpdate(p0: ByteArray?, p1: Int, p2: Int): ByteArray = wrapped.update(p0, p1, p2)
+
+        override fun engineUpdate(p0: ByteArray?, p1: Int, p2: Int, p3: ByteArray?, p4: Int): Int = wrapped.update(p0, p1, p2, p3, p4)
+
+        override fun engineGetBlockSize(): Int = wrapped.blockSize
+
+        override fun engineGetOutputSize(p0: Int): Int = wrapped.getOutputSize(p0)
+    }
+}
diff --git a/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java b/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java
new file mode 100644
index 000000000..e48afc39c
--- /dev/null
+++ b/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2015-present, Parse, LLC.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ */
+package com.parse;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.skyscreamer.jsonassert.JSONAssert.assertEquals;
+
+import android.content.Context;
+import androidx.security.crypto.EncryptedFile;
+import androidx.security.crypto.MasterKey;
+import androidx.test.platform.app.InstrumentationRegistry;
+import java.io.File;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.skyscreamer.jsonassert.JSONCompareMode;
+
+@RunWith(RobolectricTestRunner.class)
+public class EncryptedFileObjectStoreTest {
+
+    @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    @Before
+    public void setUp() {
+        RobolectricKeyStore.INSTANCE.getSetup();
+        ParseObject.registerSubclass(ParseUser.class);
+        Parse.initialize(
+                new Parse.Configuration.Builder(
+                                InstrumentationRegistry.getInstrumentation().getTargetContext())
+                        .server("http://parse.com")
+                        .build());
+    }
+
+    @After
+    public void tearDown() {
+        ParseObject.unregisterSubclass(ParseUser.class);
+    }
+
+    @Test
+    public void testSetAsync() throws Exception {
+        File file = new File(temporaryFolder.getRoot(), "test");
+
+        ParseUser.State state = mock(ParseUser.State.class);
+        JSONObject json = new JSONObject();
+        json.put("foo", "bar");
+        ParseUserCurrentCoder coder = mock(ParseUserCurrentCoder.class);
+        when(coder.encode(eq(state), isNull(), any(PointerEncoder.class))).thenReturn(json);
+        EncryptedFileObjectStore<ParseUser> store =
+                new EncryptedFileObjectStore<>(ParseUser.class, file, coder);
+
+        ParseUser user = mock(ParseUser.class);
+        when(user.getState()).thenReturn(state);
+        ParseTaskUtils.wait(store.setAsync(user));
+
+        Context context = InstrumentationRegistry.getInstrumentation().getContext();
+        EncryptedFile encryptedFile =
+                new EncryptedFile.Builder(
+                                context,
+                                file,
+                                new MasterKey.Builder(context)
+                                        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+                                        .build(),
+                                EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB)
+                        .build();
+        JSONObject jsonAgain = ParseFileUtils.readFileToJSONObject(encryptedFile);
+        assertEquals(json, jsonAgain, JSONCompareMode.STRICT);
+    }
+
+    @Test
+    public void testGetAsync() throws Exception {
+        File file = new File(temporaryFolder.getRoot(), "test");
+
+        Context context = InstrumentationRegistry.getInstrumentation().getContext();
+        EncryptedFile encryptedFile =
+                new EncryptedFile.Builder(
+                                context,
+                                file,
+                                new MasterKey.Builder(context)
+                                        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+                                        .build(),
+                                EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB)
+                        .build();
+
+        JSONObject json = new JSONObject();
+        ParseFileUtils.writeJSONObjectToFile(encryptedFile, json);
+
+        ParseUser.State.Builder builder = new ParseUser.State.Builder();
+        builder.put("foo", "bar");
+        ParseUserCurrentCoder coder = mock(ParseUserCurrentCoder.class);
+        when(coder.decode(
+                        any(ParseUser.State.Builder.class),
+                        any(JSONObject.class),
+                        any(ParseDecoder.class)))
+                .thenReturn(builder);
+        EncryptedFileObjectStore<ParseUser> store =
+                new EncryptedFileObjectStore<>(ParseUser.class, file, coder);
+
+        ParseUser user = ParseTaskUtils.wait(store.getAsync());
+        assertEquals("bar", user.getState().get("foo"));
+    }
+
+    @Test
+    public void testExistsAsync() throws Exception {
+        File file = temporaryFolder.newFile("test");
+        EncryptedFileObjectStore<ParseUser> store =
+                new EncryptedFileObjectStore<>(ParseUser.class, file, null);
+        assertTrue(ParseTaskUtils.wait(store.existsAsync()));
+
+        temporaryFolder.delete();
+        assertFalse(ParseTaskUtils.wait(store.existsAsync()));
+    }
+
+    @Test
+    public void testDeleteAsync() throws Exception {
+        File file = temporaryFolder.newFile("test");
+        EncryptedFileObjectStore<ParseUser> store =
+                new EncryptedFileObjectStore<>(ParseUser.class, file, null);
+        assertTrue(file.exists());
+
+        ParseTaskUtils.wait(store.deleteAsync());
+        assertFalse(file.exists());
+    }
+}
diff --git a/parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt b/parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt
new file mode 100644
index 000000000..5335dcc10
--- /dev/null
+++ b/parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt
@@ -0,0 +1,101 @@
+package com.parse
+
+import com.parse.boltsinternal.Task
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.robolectric.RobolectricTestRunner
+@RunWith(RobolectricTestRunner::class)
+class ParseObjectStoreMigratorTest {
+
+    private lateinit var store: ParseObjectStore<ParseObject>
+    private lateinit var legacy: ParseObjectStore<ParseObject>
+    private lateinit var migrator: ParseObjectStoreMigrator<ParseObject>
+
+    @BeforeEach
+    fun setUp() {
+        store = mock(ParseObjectStore::class.java) as ParseObjectStore<ParseObject>
+        legacy = mock(ParseObjectStore::class.java) as ParseObjectStore<ParseObject>
+        migrator = ParseObjectStoreMigrator(store, legacy)
+    }
+
+    @Test
+    fun testGetAsyncWhenStoreHasData() {
+        val parseObject = mock(ParseObject::class.java)
+        `when`(store.getAsync()).thenReturn(Task.forResult(parseObject))
+
+        val result = migrator.getAsync().result
+
+        assertEquals(parseObject, result)
+        verify(store, times(1)).getAsync()
+        verify(legacy, never()).getAsync()
+    }
+
+    @Test
+    fun testGetAsyncWhenStoreIsEmptyAndLegacyHasData() {
+        val parseObject = mock(ParseObject::class.java)
+        `when`(store.getAsync()).thenReturn(Task.forResult(null))
+        `when`(legacy.getAsync()).thenReturn(Task.forResult(parseObject))
+        `when`(legacy.deleteAsync()).thenReturn(Task.forResult(null))
+        `when`(store.setAsync(parseObject)).thenReturn(Task.forResult(null))
+
+        val result = migrator.getAsync().result
+
+        assertEquals(parseObject, result)
+        verify(store, times(1)).getAsync()
+        verify(legacy, times(1)).getAsync()
+        verify(legacy, times(1)).deleteAsync()
+        verify(store, times(1)).setAsync(parseObject)
+    }
+
+    @Test
+    fun testSetAsync() {
+        val parseObject = mock(ParseObject::class.java)
+        `when`(store.setAsync(parseObject)).thenReturn(Task.forResult(null))
+
+        migrator.setAsync(parseObject).waitForCompletion()
+
+        verify(store, times(1)).setAsync(parseObject)
+    }
+
+    @Test
+    fun testExistsAsyncWhenStoreHasData() {
+        `when`(store.existsAsync()).thenReturn(Task.forResult(true))
+
+        val result = migrator.existsAsync().result
+
+        assertTrue(result)
+        verify(store, times(1)).existsAsync()
+        verify(legacy, never()).existsAsync()
+    }
+
+    @Test
+    fun testExistsAsyncWhenStoreIsEmptyAndLegacyHasData() {
+        `when`(store.existsAsync()).thenReturn(Task.forResult(false))
+        `when`(legacy.existsAsync()).thenReturn(Task.forResult(true))
+
+        val result = migrator.existsAsync().result
+
+        assertTrue(result)
+        verify(store, times(1)).existsAsync()
+        verify(legacy, times(1)).existsAsync()
+    }
+
+    @Test
+    fun testDeleteAsync() {
+        `when`(store.deleteAsync()).thenReturn(Task.forResult(null))
+        `when`(legacy.deleteAsync()).thenReturn(Task.forResult(null))
+
+        migrator.deleteAsync().waitForCompletion()
+
+        verify(store, times(1)).deleteAsync()
+        verify(legacy, times(1)).deleteAsync()
+    }
+}
diff --git a/parse/src/test/java/com/parse/RobolectricKeyStore.kt b/parse/src/test/java/com/parse/RobolectricKeyStore.kt
new file mode 100644
index 000000000..7162ff1c5
--- /dev/null
+++ b/parse/src/test/java/com/parse/RobolectricKeyStore.kt
@@ -0,0 +1,17 @@
+package com.parse
+
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import java.security.Security
+
+object RobolectricKeyStore {
+
+    val setup by lazy {
+        Security.removeProvider("AndroidKeyStore")
+        Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
+        Security.removeProvider("AndroidOpenSSL")
+
+        Security.addProvider(AndroidKeyStoreProvider())
+        Security.addProvider(BouncyCastleProvider())
+        Security.addProvider(AndroidOpenSSLProvider())
+    }
+}
diff --git a/twitter/src/main/java/com/parse/twitter/ParseTwitterUtils.java b/twitter/src/main/java/com/parse/twitter/ParseTwitterUtils.java
index 76707cb6f..2966df279 100644
--- a/twitter/src/main/java/com/parse/twitter/ParseTwitterUtils.java
+++ b/twitter/src/main/java/com/parse/twitter/ParseTwitterUtils.java
@@ -108,7 +108,9 @@ private static void checkInitialization() {
         }
     }
 
-    /** @return {@code true} if the user is linked to a Twitter account. */
+    /**
+     * @return {@code true} if the user is linked to a Twitter account.
+     */
     public static boolean isLinked(ParseUser user) {
         return user.isLinked(AUTH_TYPE);
     }