summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/pull_request.yml86
-rw-r--r--buildSrc/src/main/java/BaseProjectConfig.kt3
-rw-r--r--buildSrc/src/main/java/SlimTests.kt6
-rw-r--r--crypto-common/api/crypto-common.api57
-rw-r--r--crypto-common/build.gradle.kts2
-rw-r--r--crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt19
-rw-r--r--crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt19
-rw-r--r--crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.kt14
-rw-r--r--crypto-pgp/api/crypto-pgp.api18
-rw-r--r--crypto-pgp/build.gradle.kts14
-rw-r--r--crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt165
-rw-r--r--crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt52
-rw-r--r--crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/CryptoConstants.kt14
-rw-r--r--crypto-pgp/src/androidTest/res/raw/private_key18
-rw-r--r--crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt95
-rw-r--r--crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt28
-rwxr-xr-xscripts/boot-emulator.sh29
-rwxr-xr-xscripts/setup-environment.sh42
18 files changed, 673 insertions, 8 deletions
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 994a5ca0..ffe8acae 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -2,13 +2,9 @@ on: [pull_request]
name: Check pull request
jobs:
- test-pr:
+ unit-tests:
runs-on: ubuntu-latest
steps:
-
- #- name: Auto-cancel redundant workflow run
- # uses: technote-space/auto-cancel-redundant-workflow@f9dfa1c127a520e4d71b92892850f861fb861206
-
- name: Check if relevant files have changed
uses: actions/github-script@a3e7071a34d7e1f219a8a4de9a5e0a34d1ee1293
id: service-changed
@@ -43,8 +39,6 @@ jobs:
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
with:
fetch-depth: 0
- #with:
- # ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Copy CI gradle.properties
if: ${{ steps.service-changed.outputs.result == 'true' }}
@@ -62,3 +56,81 @@ jobs:
with:
name: Test report
path: app/build/reports
+
+ run-screenshot-tests:
+ runs-on: macOS-latest
+ steps:
+ - name: Check if relevant files have changed
+ uses: actions/github-script@a3e7071a34d7e1f219a8a4de9a5e0a34d1ee1293
+ id: service-changed
+ with:
+ result-encoding: string
+ script: |
+ const result = await github.pulls.listFiles({
+ owner: context.payload.repository.owner.login,
+ repo: context.payload.repository.name,
+ pull_number: context.payload.number,
+ per_page: 100
+ })
+ const files = result.data.filter(file =>
+ // We wanna run this if the PR workflow is modified
+ (file.filename.endsWith(".yml") && !file.filename.endsWith("pull_request.yml")) ||
+ // Changes in Markdown files don't need tests
+ file.filename.endsWith(".md") ||
+ // Changes to fastlane metadata aren't covered by tests
+ file.filename.startsWith("fastlane/")
+ )
+ // If filtered file count and source file count is equal, it means all files
+ // in this PR are skip-worthy.
+ return files.length != result.data.length
+
+ - uses: actions/cache@c64c572235d810460d0d6876e9c705ad5002b353
+ if: ${{ steps.service-changed.outputs.result == 'true' }}
+ id: avd-cache
+ with:
+ path: |
+ ~/.android/avd/*
+ ~/.android/adb*
+ key: avd-23
+
+ - uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2
+ if: ${{ steps.service-changed.outputs.result == 'true' }}
+ with:
+ java-version: '11'
+
+ - name: Checkout repository
+ if: ${{ steps.service-changed.outputs.result == 'true' }}
+ uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
+ with:
+ fetch-depth: 0
+
+ - name: Copy CI gradle.properties
+ if: ${{ steps.service-changed.outputs.result == 'true' }}
+ run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
+
+ - name: Create AVD and generate snapshot for caching
+ uses: reactivecircus/android-emulator-runner@5de26e4bd23bf523e8a4b7f077df8bfb8e52b50e
+ if: ${{ steps.avd-cache.outputs.cache-hit != 'true' }}
+ with:
+ api-level: 23
+ force-avd-creation: false
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ disable-animations: false
+ script: echo "Generated AVD snapshot for caching"
+
+ - name: Run screenshot tests
+ uses: reactivecircus/android-emulator-runner@5de26e4bd23bf523e8a4b7f077df8bfb8e52b50e
+ if: ${{ steps.service-changed.outputs.result == 'true' }}
+ with:
+ api-level: 23
+ force-avd-creation: false
+ emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ disable-animations: true
+ script: ./gradlew connectedCheck -PslimTests
+
+ - name: (Fail-only) upload test report
+ if: failure()
+ uses: actions/upload-artifact@27121b0bdffd731efa15d66772be8dc71245d074
+ with:
+ name: Test report
+ path: app/build/reports
diff --git a/buildSrc/src/main/java/BaseProjectConfig.kt b/buildSrc/src/main/java/BaseProjectConfig.kt
index f3a33713..f9687c3c 100644
--- a/buildSrc/src/main/java/BaseProjectConfig.kt
+++ b/buildSrc/src/main/java/BaseProjectConfig.kt
@@ -121,6 +121,7 @@ internal fun TestedExtension.configureCommonAndroidOptions() {
sourceSets {
named("main") { java.srcDirs("src/main/kotlin") }
named("test") { java.srcDirs("src/test/kotlin") }
+ named("androidTest") { java.srcDirs("src/androidTest/kotlin") }
}
packagingOptions {
@@ -128,6 +129,8 @@ internal fun TestedExtension.configureCommonAndroidOptions() {
resources.excludes.add("**/*.txt")
resources.excludes.add("**/*.kotlin_module")
resources.excludes.add("**/plugin.properties")
+ resources.excludes.add("**/META-INF/AL2.0")
+ resources.excludes.add("**/META-INF/LGPL2.1")
}
compileOptions {
diff --git a/buildSrc/src/main/java/SlimTests.kt b/buildSrc/src/main/java/SlimTests.kt
index e06c7c8c..4057032b 100644
--- a/buildSrc/src/main/java/SlimTests.kt
+++ b/buildSrc/src/main/java/SlimTests.kt
@@ -21,7 +21,10 @@ internal fun Project.configureSlimTests() {
if (providers.gradleProperty(SLIM_TESTS_PROPERTY).forUseAtConfigurationTime().isPresent) {
// disable unit test tasks on the release build type for Android Library projects
extensions.findByType<LibraryAndroidComponentsExtension>()?.run {
- beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { it.enableUnitTest = false }
+ beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) {
+ it.enableUnitTest = false
+ it.enableAndroidTest = false
+ }
}
// disable unit test tasks on the release build type and free flavor for Android Application
@@ -30,6 +33,7 @@ internal fun Project.configureSlimTests() {
beforeVariants(selector().withBuildType(BuildType.RELEASE.name)) { it.enableUnitTest = false }
beforeVariants(selector().withFlavor(FlavorDimensions.FREE to ProductFlavors.NON_FREE)) {
it.enableUnitTest = false
+ it.enableAndroidTest = false
}
}
}
diff --git a/crypto-common/api/crypto-common.api b/crypto-common/api/crypto-common.api
index 7493379c..6468ea9b 100644
--- a/crypto-common/api/crypto-common.api
+++ b/crypto-common/api/crypto-common.api
@@ -1,6 +1,63 @@
+public abstract class dev/msfjarvis/aps/data/crypto/CryptoException : java/lang/Exception {
+ public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+}
+
public abstract interface class dev/msfjarvis/aps/data/crypto/CryptoHandler {
public abstract fun canHandle (Ljava/lang/String;)Z
public abstract fun decrypt (Ljava/lang/String;[B[B)[B
public abstract fun encrypt (Ljava/lang/String;[B)[B
}
+public abstract interface class dev/msfjarvis/aps/data/crypto/KeyManager {
+ public abstract fun addKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public abstract fun canHandle (Ljava/lang/String;)Z
+ public abstract fun getAllKeys (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public abstract fun getKeyById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public abstract fun removeKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyManager$DefaultImpls {
+ public static synthetic fun addKey$default (Ldev/msfjarvis/aps/data/crypto/KeyManager;Ldev/msfjarvis/aps/data/crypto/KeyPair;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
+public abstract class dev/msfjarvis/aps/data/crypto/KeyManagerException : dev/msfjarvis/aps/data/crypto/CryptoException {
+ public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyAlreadyExistsException : dev/msfjarvis/aps/data/crypto/KeyManagerException {
+ public fun <init> (Ljava/lang/String;)V
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDeletionFailedException : dev/msfjarvis/aps/data/crypto/KeyManagerException {
+ public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDeletionFailedException;
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDirectoryUnavailableException : dev/msfjarvis/aps/data/crypto/KeyManagerException {
+ public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyManagerException$KeyDirectoryUnavailableException;
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$KeyNotFoundException : dev/msfjarvis/aps/data/crypto/KeyManagerException {
+ public fun <init> (Ljava/lang/String;)V
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyManagerException$NoKeysAvailableException : dev/msfjarvis/aps/data/crypto/KeyManagerException {
+ public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyManagerException$NoKeysAvailableException;
+}
+
+public abstract interface class dev/msfjarvis/aps/data/crypto/KeyPair {
+ public abstract fun getKeyId ()Ljava/lang/String;
+ public abstract fun getPrivateKey ()[B
+ public abstract fun getPublicKey ()[B
+}
+
+public abstract class dev/msfjarvis/aps/data/crypto/KeyPairException : dev/msfjarvis/aps/data/crypto/CryptoException {
+ public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+}
+
+public final class dev/msfjarvis/aps/data/crypto/KeyPairException$PrivateKeyUnavailableException : dev/msfjarvis/aps/data/crypto/KeyPairException {
+ public static final field INSTANCE Ldev/msfjarvis/aps/data/crypto/KeyPairException$PrivateKeyUnavailableException;
+}
+
diff --git a/crypto-common/build.gradle.kts b/crypto-common/build.gradle.kts
index c1f3eef8..44323900 100644
--- a/crypto-common/build.gradle.kts
+++ b/crypto-common/build.gradle.kts
@@ -6,3 +6,5 @@ plugins {
kotlin("jvm")
`aps-plugin`
}
+
+dependencies { implementation(libs.thirdparty.kotlinResult) }
diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt
new file mode 100644
index 00000000..6a73d381
--- /dev/null
+++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/CryptoException.kt
@@ -0,0 +1,19 @@
+package dev.msfjarvis.aps.data.crypto
+
+public sealed class CryptoException(message: String? = null) : Exception(message)
+
+public sealed class KeyPairException(message: String? = null) : CryptoException(message) {
+ public object PrivateKeyUnavailableException :
+ KeyPairException("Key object does not have a private sub-key")
+}
+
+public sealed class KeyManagerException(message: String? = null) : CryptoException(message) {
+ public object NoKeysAvailableException : KeyManagerException("No keys were found")
+ public object KeyDirectoryUnavailableException :
+ KeyManagerException("Key directory does not exist")
+ public object KeyDeletionFailedException : KeyManagerException("Couldn't delete the key file")
+ public class KeyNotFoundException(keyId: String) :
+ KeyManagerException("No key found with id: $keyId")
+ public class KeyAlreadyExistsException(keyId: String) :
+ KeyManagerException("Pre-existing key was found for $keyId but 'replace' is set to false")
+}
diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt
new file mode 100644
index 00000000..b5ba881e
--- /dev/null
+++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyManager.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.data.crypto
+
+import com.github.michaelbull.result.Result
+
+public interface KeyManager<T : KeyPair> {
+
+ public suspend fun addKey(key: T, replace: Boolean = false): Result<T, Throwable>
+ public suspend fun removeKey(key: T): Result<T, Throwable>
+ public suspend fun getKeyById(id: String): Result<T, Throwable>
+ public suspend fun getAllKeys(): Result<List<T>, Throwable>
+
+ /** Given a [fileName], return whether this instance can handle it. */
+ public fun canHandle(fileName: String): Boolean
+}
diff --git a/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.kt b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.kt
new file mode 100644
index 00000000..e2362612
--- /dev/null
+++ b/crypto-common/src/main/kotlin/dev/msfjarvis/aps/data/crypto/KeyPair.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.data.crypto
+
+/** Defines expectations for a keypair used in public key cryptography. */
+public interface KeyPair {
+
+ public fun getPrivateKey(): ByteArray
+ public fun getPublicKey(): ByteArray
+ public fun getKeyId(): String
+}
diff --git a/crypto-pgp/api/crypto-pgp.api b/crypto-pgp/api/crypto-pgp.api
index 2164360c..c9b2dde7 100644
--- a/crypto-pgp/api/crypto-pgp.api
+++ b/crypto-pgp/api/crypto-pgp.api
@@ -1,3 +1,21 @@
+public final class dev/msfjarvis/aps/data/crypto/GPGKeyManager : dev/msfjarvis/aps/data/crypto/KeyManager {
+ public fun <init> (Ljava/lang/String;Lkotlinx/coroutines/CoroutineDispatcher;)V
+ public fun addKey (Ldev/msfjarvis/aps/data/crypto/GPGKeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public synthetic fun addKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public fun canHandle (Ljava/lang/String;)Z
+ public fun getAllKeys (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public fun getKeyById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public fun removeKey (Ldev/msfjarvis/aps/data/crypto/GPGKeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public synthetic fun removeKey (Ldev/msfjarvis/aps/data/crypto/KeyPair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class dev/msfjarvis/aps/data/crypto/GPGKeyPair : dev/msfjarvis/aps/data/crypto/KeyPair {
+ public fun <init> (Lcom/proton/Gopenpgp/crypto/Key;)V
+ public fun getKeyId ()Ljava/lang/String;
+ public fun getPrivateKey ()[B
+ public fun getPublicKey ()[B
+}
+
public final class dev/msfjarvis/aps/data/crypto/GopenpgpCryptoHandler : dev/msfjarvis/aps/data/crypto/CryptoHandler {
public fun <init> ()V
public fun canHandle (Ljava/lang/String;)Z
diff --git a/crypto-pgp/build.gradle.kts b/crypto-pgp/build.gradle.kts
index 493062b6..95542b1c 100644
--- a/crypto-pgp/build.gradle.kts
+++ b/crypto-pgp/build.gradle.kts
@@ -2,14 +2,28 @@
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
+
plugins {
id("com.android.library")
kotlin("android")
`aps-plugin`
}
+android {
+ defaultConfig {
+ testApplicationId = "dev.msfjarvis.aps.cryptopgp.test"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+}
+
dependencies {
api(projects.cryptoCommon)
+ implementation(libs.androidx.annotation)
implementation(libs.aps.gopenpgp)
implementation(libs.dagger.hilt.core)
+ implementation(libs.kotlin.coroutines.core)
+ implementation(libs.thirdparty.kotlinResult)
+ androidTestImplementation(libs.bundles.testDependencies)
+ androidTestImplementation(libs.kotlin.coroutines.test)
+ androidTestImplementation(libs.bundles.androidTestDependencies)
}
diff --git a/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt
new file mode 100644
index 00000000..80a13eb5
--- /dev/null
+++ b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyManagerTest.kt
@@ -0,0 +1,165 @@
+package dev.msfjarvis.aps.crypto
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.github.michaelbull.result.unwrap
+import com.github.michaelbull.result.unwrapError
+import com.proton.Gopenpgp.crypto.Key
+import dev.msfjarvis.aps.crypto.utils.CryptoConstants
+import dev.msfjarvis.aps.cryptopgp.test.R
+import dev.msfjarvis.aps.data.crypto.GPGKeyManager
+import dev.msfjarvis.aps.data.crypto.GPGKeyPair
+import dev.msfjarvis.aps.data.crypto.KeyManagerException
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+public class GPGKeyManagerTest {
+
+ private val testCoroutineDispatcher = TestCoroutineDispatcher()
+ private lateinit var gpgKeyManager: GPGKeyManager
+ private lateinit var key: GPGKeyPair
+
+ @Before
+ public fun setup() {
+ gpgKeyManager = GPGKeyManager(getFilesDir().absolutePath, testCoroutineDispatcher)
+ key = GPGKeyPair(Key(getKey()))
+ }
+
+ @After
+ public fun tearDown() {
+ val filesDir = getFilesDir()
+ val keysDir = File(filesDir, GPGKeyManager.KEY_DIR_NAME)
+
+ keysDir.deleteRecursively()
+ }
+
+ @Test
+ public fun testAddingKey() {
+ runBlockingTest {
+ // Check if the key id returned is correct
+ val keyId = gpgKeyManager.addKey(key).unwrap().getKeyId()
+ assertEquals(CryptoConstants.KEY_ID, keyId)
+
+ // Check if the keys directory have one file
+ val keysDir = File(getFilesDir(), GPGKeyManager.KEY_DIR_NAME)
+ assertEquals(1, keysDir.list()?.size)
+
+ // Check if the file name is correct
+ val keyFile = keysDir.listFiles()?.first()
+ assertEquals(keyFile?.name, "$keyId.${GPGKeyManager.KEY_EXTENSION}")
+ }
+ }
+
+ @Test
+ public fun testAddingKeyWithoutReplaceFlag() {
+ runBlockingTest {
+ // Check adding the keys twice
+ gpgKeyManager.addKey(key, false).unwrap()
+ val error = gpgKeyManager.addKey(key, false).unwrapError()
+
+ assertIs<KeyManagerException.KeyAlreadyExistsException>(error)
+ }
+ }
+
+ @Test
+ public fun testAddingKeyWithReplaceFlag() {
+ runBlockingTest {
+ // Check adding the keys twice
+ gpgKeyManager.addKey(key, true).unwrap()
+ val keyId = gpgKeyManager.addKey(key, true).unwrap().getKeyId()
+
+ assertEquals(CryptoConstants.KEY_ID, keyId)
+ }
+ }
+
+ @Test
+ public fun testRemovingKey() {
+ runBlockingTest {
+ // Add key using KeyManager
+ gpgKeyManager.addKey(key).unwrap()
+
+ // Check if the key id returned is correct
+ val keyId = gpgKeyManager.removeKey(key).unwrap().getKeyId()
+ assertEquals(CryptoConstants.KEY_ID, keyId)
+
+ // Check if the keys directory have 0 files
+ val keysDir = File(getFilesDir(), GPGKeyManager.KEY_DIR_NAME)
+ assertEquals(0, keysDir.list()?.size)
+ }
+ }
+
+ @Test
+ public fun testGetExistingKey() {
+ runBlockingTest {
+ // Add key using KeyManager
+ gpgKeyManager.addKey(key).unwrap()
+
+ // Check returned key id matches the expected id and the created key id
+ val returnedKeyPair = gpgKeyManager.getKeyById(key.getKeyId()).unwrap()
+ assertEquals(CryptoConstants.KEY_ID, key.getKeyId())
+ assertEquals(key.getKeyId(), returnedKeyPair.getKeyId())
+ }
+ }
+
+ @Test
+ public fun testGetNonExistentKey() {
+ runBlockingTest {
+ // Add key using KeyManager
+ gpgKeyManager.addKey(key).unwrap()
+
+ val randomKeyId = "0x123456789"
+
+ // Check returned key
+ val error = gpgKeyManager.getKeyById(randomKeyId).unwrapError()
+ assertIs<KeyManagerException.KeyNotFoundException>(error)
+ assertEquals("No key found with id: $randomKeyId", error.message)
+ }
+ }
+
+ @Test
+ public fun testFindKeysWithoutAdding() {
+ runBlockingTest {
+ // Check returned key
+ val error = gpgKeyManager.getKeyById("0x123456789").unwrapError()
+ assertIs<KeyManagerException.NoKeysAvailableException>(error)
+ assertEquals("No keys were found", error.message)
+ }
+ }
+
+ @Test
+ public fun testGettingAllKeys() {
+ runBlockingTest {
+ // TODO: Should we check for more than 1 keys?
+ // Check if KeyManager returns no key
+ val noKeyList = gpgKeyManager.getAllKeys().unwrap()
+ assertEquals(0, noKeyList.size)
+
+ // Add key using KeyManager
+ gpgKeyManager.addKey(key).unwrap()
+
+ // Check if KeyManager returns one key
+ val singleKeyList = gpgKeyManager.getAllKeys().unwrap()
+ assertEquals(1, singleKeyList.size)
+ }
+ }
+
+ private companion object {
+
+ fun getFilesDir(): File = InstrumentationRegistry.getInstrumentation().context.filesDir
+
+ fun getKey(): String =
+ InstrumentationRegistry.getInstrumentation()
+ .context
+ .resources
+ .openRawResource(R.raw.private_key)
+ .readBytes()
+ .decodeToString()
+ }
+}
diff --git a/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt
new file mode 100644
index 00000000..2340d9a5
--- /dev/null
+++ b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/GPGKeyPairTest.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.crypto
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.proton.Gopenpgp.crypto.Key
+import dev.msfjarvis.aps.crypto.utils.CryptoConstants
+import dev.msfjarvis.aps.cryptopgp.test.R
+import dev.msfjarvis.aps.data.crypto.GPGKeyPair
+import dev.msfjarvis.aps.data.crypto.KeyPairException
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import org.junit.Test
+
+public class GPGKeyPairTest {
+
+ @Test
+ public fun testIfKeyIdIsCorrect() {
+ val gpgKey = Key(getKey())
+ val keyPair = GPGKeyPair(gpgKey)
+
+ assertEquals(CryptoConstants.KEY_ID, keyPair.getKeyId())
+ }
+
+ @Test
+ public fun testBuildingKeyPairWithoutPrivateKey() {
+ assertFailsWith<KeyPairException.PrivateKeyUnavailableException>(
+ "GPGKeyPair does not have a private sub key"
+ ) {
+ // Get public key object from private key
+ val gpgKey = Key(getKey()).toPublic()
+ // Try creating a KeyPair from public key
+ val keyPair = GPGKeyPair(gpgKey)
+
+ keyPair.getPrivateKey()
+ }
+ }
+
+ private companion object {
+
+ fun getKey(): String =
+ InstrumentationRegistry.getInstrumentation()
+ .context
+ .resources
+ .openRawResource(R.raw.private_key)
+ .readBytes()
+ .decodeToString()
+ }
+}
diff --git a/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/CryptoConstants.kt b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/CryptoConstants.kt
new file mode 100644
index 00000000..873f7105
--- /dev/null
+++ b/crypto-pgp/src/androidTest/kotlin/dev/msfjarvis/aps/crypto/utils/CryptoConstants.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.crypto.utils
+
+internal object CryptoConstants {
+ internal const val KEY_PASSPHRASE = "hunter2"
+ internal const val PLAIN_TEXT = "encryption worthy content"
+ internal const val KEY_NAME = "John Doe"
+ internal const val KEY_EMAIL = "john.doe@example.com"
+ internal const val KEY_ID = "04ace699d5b15b7e"
+}
diff --git a/crypto-pgp/src/androidTest/res/raw/private_key b/crypto-pgp/src/androidTest/res/raw/private_key
new file mode 100644
index 00000000..5a4f436c
--- /dev/null
+++ b/crypto-pgp/src/androidTest/res/raw/private_key
@@ -0,0 +1,18 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: GopenPGP 2.1.9
+Comment: https://gopenpgp.org
+
+xYYEYN+EThYJKwYBBAHaRw8BAQdAh0d9GdVyJV6KbFynPz3sHkdi5RDnKYs+l0x0
+rEOEthX+CQMIfg7BTvTTe7pgvNERA1vLXRjSxXyi7tfSV13JRnrapp7YtNUSHLVS
+PqbaLBd6+EXx7dJ9mUSUSWVga5mdtLZ/k6e+6dsygeHiJuwxfGbHnc0fSm9obiBE
+b2UgPGpvaG4uZG9lQGV4YW1wbGUuY29tPsKIBBMWCAA6BQJg34ROCRAErOaZ1bFb
+fhYhBJQ0DPsSHC5XfslyQwSs5pnVsVt+AhsDAh4BAhkBAwsJBwIVCAIiAQAAtgwB
+AOa3rnipQPsxgxvOP1V+2kD6ssiwt6BZRWwPcyfeX1h4AP9ozBYr+PSmNbam9bnq
+wgXwuQhPJeWTSgILMaiasugGCMeLBGDfhE4SCisGAQQBl1UBBQEBB0ClFQJX/L2G
+EX9ucC5mvwj3X/7aDXDFAmIpQeWYSS1negMBCgn+CQMIF1uko+Ym3thgoDWUgM5e
+MNmDG3rYkTa7h6mlhhrsYtE/GN78EJHP1ygFzOczU/YdbxSRTZCu697uPCZLWURV
+1+b66KLTMNHNaAkoFb2JC8J4BBgWCAAqBQJg34ROCRAErOaZ1bFbfhYhBJQ0DPsS
+HC5XfslyQwSs5pnVsVt+AhsMAAB1CgEApNcEivCSp0f8CnV4UCoSRRRekIbP1Ub2
+GJx6iRJR8xwA/jicDxdnl/Umfd3mWjGk04R47whiDOXdwjBmC1KVBaMH
+=Sfsa
+-----END PGP PRIVATE KEY BLOCK----- \ No newline at end of file
diff --git a/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt
new file mode 100644
index 00000000..478d2700
--- /dev/null
+++ b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyManager.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.data.crypto
+
+import androidx.annotation.VisibleForTesting
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.runCatching
+import com.proton.Gopenpgp.crypto.Crypto
+import java.io.File
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+
+public class GPGKeyManager(filesDir: String, private val dispatcher: CoroutineDispatcher) :
+ KeyManager<GPGKeyPair> {
+
+ private val keyDir = File(filesDir, KEY_DIR_NAME)
+
+ override suspend fun addKey(key: GPGKeyPair, replace: Boolean): Result<GPGKeyPair, Throwable> =
+ withContext(dispatcher) {
+ runCatching {
+ if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException
+ val keyFile = File(keyDir, "${key.getKeyId()}.$KEY_EXTENSION")
+ if (keyFile.exists()) {
+ // Check for replace flag first and if it is false, throw an error
+ if (!replace) throw KeyManagerException.KeyAlreadyExistsException(key.getKeyId())
+ if (!keyFile.delete()) throw KeyManagerException.KeyDeletionFailedException
+ }
+
+ keyFile.writeBytes(key.getPrivateKey())
+
+ key
+ }
+ }
+
+ override suspend fun removeKey(key: GPGKeyPair): Result<GPGKeyPair, Throwable> =
+ withContext(dispatcher) {
+ runCatching {
+ if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException
+ val keyFile = File(keyDir, "${key.getKeyId()}.$KEY_EXTENSION")
+ if (keyFile.exists()) {
+ if (!keyFile.delete()) throw KeyManagerException.KeyDeletionFailedException
+ }
+
+ key
+ }
+ }
+
+ override suspend fun getKeyById(id: String): Result<GPGKeyPair, Throwable> =
+ withContext(dispatcher) {
+ runCatching {
+ if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException
+ val keys = keyDir.listFiles()
+ if (keys.isNullOrEmpty()) throw KeyManagerException.NoKeysAvailableException
+
+ for (keyFile in keys) {
+ val keyPair = GPGKeyPair(Crypto.newKeyFromArmored(keyFile.readText()))
+ if (keyPair.getKeyId() == id) return@runCatching keyPair
+ }
+
+ throw KeyManagerException.KeyNotFoundException(id)
+ }
+ }
+
+ override suspend fun getAllKeys(): Result<List<GPGKeyPair>, Throwable> =
+ withContext(dispatcher) {
+ runCatching {
+ if (!keyDirExists()) throw KeyManagerException.KeyDirectoryUnavailableException
+ val keys = keyDir.listFiles()
+ if (keys.isNullOrEmpty()) return@runCatching listOf()
+
+ keys.map { GPGKeyPair(Crypto.newKeyFromArmored(it.readText())) }.toList()
+ }
+ }
+
+ override fun canHandle(fileName: String): Boolean {
+ // TODO: This is a temp hack for now and in future it should check that the GPGKeyManager can
+ // decrypt the file
+ return fileName.endsWith(KEY_EXTENSION)
+ }
+
+ private fun keyDirExists(): Boolean {
+ return keyDir.exists() || keyDir.mkdirs()
+ }
+
+ internal companion object {
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal const val KEY_DIR_NAME: String = "keys"
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal const val KEY_EXTENSION: String = "key"
+ }
+}
diff --git a/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt
new file mode 100644
index 00000000..2dbe8689
--- /dev/null
+++ b/crypto-pgp/src/main/kotlin/dev/msfjarvis/aps/data/crypto/GPGKeyPair.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package dev.msfjarvis.aps.data.crypto
+
+import com.proton.Gopenpgp.crypto.Key
+
+/** Wraps a Gopenpgp [Key] to implement [KeyPair]. */
+public class GPGKeyPair(private val key: Key) : KeyPair {
+
+ init {
+ if (!key.isPrivate) throw KeyPairException.PrivateKeyUnavailableException
+ }
+
+ override fun getPrivateKey(): ByteArray {
+ return key.armor().encodeToByteArray()
+ }
+
+ override fun getPublicKey(): ByteArray {
+ return key.armoredPublicKey.encodeToByteArray()
+ }
+
+ override fun getKeyId(): String {
+ return key.hexKeyID
+ }
+}
diff --git a/scripts/boot-emulator.sh b/scripts/boot-emulator.sh
new file mode 100755
index 00000000..abd73efa
--- /dev/null
+++ b/scripts/boot-emulator.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+# Boots an emulator that exactly matches the one in our CI. It is recommended
+# to use this as the target device for android tests.
+
+set -euo pipefail
+
+[ -n "${ANDROID_SDK_ROOT:-}" ] || {
+ echo "ANDROID_SDK_ROOT must be set to use this script"
+ exit
+ 1
+}
+[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 30"; }
+
+API_LEVEL="${ANDROID_API_LEVEL:-30}"
+
+echo no | "${ANDROID_SDK_ROOT}"/cmdline-tools/latest/bin/avdmanager create avd \
+ --force \
+ -n "Pixel_XL_API_${API_LEVEL}" \
+ --abi 'google_apis/x86' \
+ --package "system-images;android-${API_LEVEL};google_apis;x86" \
+ --device 'pixel_xl'
+
+"${ANDROID_SDK_ROOT}"/emulator/emulator \
+ -avd "Pixel_XL_API_${API_LEVEL}" \
+ -no-window \
+ -gpu swiftshader_indirect \
+ -noaudio \
+ -no-boot-anim
diff --git a/scripts/setup-environment.sh b/scripts/setup-environment.sh
new file mode 100755
index 00000000..30dd0c19
--- /dev/null
+++ b/scripts/setup-environment.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+
+# Installs the latest command line tools and sets up the necessary packages for an Android emulator
+# for API level $ANDROID_API_LEVEL, or API 30 if unspecified.
+
+set -euo pipefail
+
+CMDLINE_TOOLS_URL_MAC="https://dl.google.com/android/repository/commandlinetools-mac-7583922_latest.zip"
+CMDLINE_TOOLS_URL_LINUX="https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip"
+
+[ -n "${ANDROID_SDK_ROOT:-}" ] || {
+ echo "ANDROID_SDK_ROOT must be set to use this script"
+ exit
+ 1
+}
+
+if [ "$(uname)" == "Linux" ]; then
+ wget "${CMDLINE_TOOLS_URL_LINUX}" -O /tmp/tools.zip -o /dev/null
+elif [ "$(uname)" == "Darwin" ]; then
+ wget "${CMDLINE_TOOLS_URL_MAC}" -O /tmp/tools.zip -o /dev/null
+else
+ echo "This script only works on Linux and Mac"
+ exit 1
+fi
+
+[ -n "${ANDROID_API_LEVEL:-}" ] || { echo "ANDROID_API_LEVEL not defined; defaulting to 30"; }
+
+API_LEVEL="${ANDROID_API_LEVEL:-30}"
+
+unzip -qo /tmp/tools.zip -d "${ANDROID_SDK_ROOT}/latest"
+mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"
+if [ -d "${ANDROID_SDK_ROOT}/cmdline-tools" ]; then
+ rm -rf "${ANDROID_SDK_ROOT}/cmdline-tools"
+fi
+mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"
+mv -v "${ANDROID_SDK_ROOT}/latest/cmdline-tools" "${ANDROID_SDK_ROOT}/cmdline-tools/latest"
+
+export PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${PATH}"
+
+sdkmanager --install 'build-tools;30.0.3' platform-tools "platforms;android-${API_LEVEL}"
+sdkmanager --install emulator
+sdkmanager --install "system-images;android-${API_LEVEL};google_apis;x86"