Merge pull request #209 from grote/main-key
Store main key for key derivations from 512-bit BIP39 recovery code
This commit is contained in:
commit
ecdc0c2716
10 changed files with 231 additions and 38 deletions
|
@ -34,7 +34,7 @@ import org.koin.dsl.module
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
* @author Torsten Grote
|
* @author Torsten Grote
|
||||||
*/
|
*/
|
||||||
class App : Application() {
|
open class App : Application() {
|
||||||
|
|
||||||
private val appModule = module {
|
private val appModule = module {
|
||||||
single { SettingsManager(this@App) }
|
single { SettingsManager(this@App) }
|
||||||
|
@ -52,22 +52,7 @@ class App : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
startKoin {
|
startKoin()
|
||||||
androidLogger()
|
|
||||||
androidContext(this@App)
|
|
||||||
modules(
|
|
||||||
listOf(
|
|
||||||
cryptoModule,
|
|
||||||
headerModule,
|
|
||||||
metadataModule,
|
|
||||||
documentsProviderModule, // storage plugin
|
|
||||||
backupModule,
|
|
||||||
restoreModule,
|
|
||||||
installModule,
|
|
||||||
appModule
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isDebugBuild()) {
|
if (isDebugBuild()) {
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder()
|
StrictMode.ThreadPolicy.Builder()
|
||||||
|
@ -88,6 +73,23 @@ class App : Application() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected open fun startKoin() = startKoin {
|
||||||
|
androidLogger()
|
||||||
|
androidContext(this@App)
|
||||||
|
modules(
|
||||||
|
listOf(
|
||||||
|
cryptoModule,
|
||||||
|
headerModule,
|
||||||
|
metadataModule,
|
||||||
|
documentsProviderModule, // storage plugin
|
||||||
|
backupModule,
|
||||||
|
restoreModule,
|
||||||
|
installModule,
|
||||||
|
appModule
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
private val metadataManager: MetadataManager by inject()
|
private val metadataManager: MetadataManager by inject()
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
package com.stevesoltys.seedvault.crypto
|
package com.stevesoltys.seedvault.crypto
|
||||||
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||||
|
|
||||||
val cryptoModule = module {
|
val cryptoModule = module {
|
||||||
factory<CipherFactory> { CipherFactoryImpl(get()) }
|
factory<CipherFactory> { CipherFactoryImpl(get()) }
|
||||||
single<KeyManager> { KeyManagerImpl() }
|
single<KeyManager> {
|
||||||
|
val keyStore by lazy {
|
||||||
|
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyManagerImpl(keyStore)
|
||||||
|
}
|
||||||
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import android.security.keystore.KeyProperties.BLOCK_MODE_GCM
|
||||||
import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE
|
import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE
|
||||||
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
|
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
|
||||||
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
|
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
|
||||||
|
import android.security.keystore.KeyProperties.PURPOSE_SIGN
|
||||||
|
import android.security.keystore.KeyProperties.PURPOSE_VERIFY
|
||||||
import android.security.keystore.KeyProtection
|
import android.security.keystore.KeyProtection
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.KeyStore.SecretKeyEntry
|
import java.security.KeyStore.SecretKeyEntry
|
||||||
|
@ -12,8 +14,10 @@ import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
internal const val KEY_SIZE = 256
|
internal const val KEY_SIZE = 256
|
||||||
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
|
internal const val KEY_SIZE_BYTES = KEY_SIZE / 8
|
||||||
private const val KEY_ALIAS = "com.stevesoltys.seedvault"
|
private const val KEY_ALIAS_BACKUP = "com.stevesoltys.seedvault"
|
||||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
private const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
|
||||||
|
private const val KEY_ALGORITHM_BACKUP = "AES"
|
||||||
|
private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
|
||||||
|
|
||||||
interface KeyManager {
|
interface KeyManager {
|
||||||
/**
|
/**
|
||||||
|
@ -23,11 +27,24 @@ interface KeyManager {
|
||||||
*/
|
*/
|
||||||
fun storeBackupKey(seed: ByteArray)
|
fun storeBackupKey(seed: ByteArray)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new main key derived from the given [seed].
|
||||||
|
*
|
||||||
|
* The seed needs to be larger or equal to two times [KEY_SIZE_BYTES]
|
||||||
|
* and is usually the same as for [storeBackupKey].
|
||||||
|
*/
|
||||||
|
fun storeMainKey(seed: ByteArray)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return true if a backup key already exists in the [KeyStore].
|
* @return true if a backup key already exists in the [KeyStore].
|
||||||
*/
|
*/
|
||||||
fun hasBackupKey(): Boolean
|
fun hasBackupKey(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true if a main key already exists in the [KeyStore].
|
||||||
|
*/
|
||||||
|
fun hasMainKey(): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the backup key, so it can be used for encryption or decryption.
|
* Returns the backup key, so it can be used for encryption or decryption.
|
||||||
*
|
*
|
||||||
|
@ -35,32 +52,49 @@ interface KeyManager {
|
||||||
* because the key can not leave the [KeyStore]'s hardware security module.
|
* because the key can not leave the [KeyStore]'s hardware security module.
|
||||||
*/
|
*/
|
||||||
fun getBackupKey(): SecretKey
|
fun getBackupKey(): SecretKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the main key, so it can be used for deriving sub-keys.
|
||||||
|
*
|
||||||
|
* Note that any attempt to export the key will return null or an empty [ByteArray],
|
||||||
|
* because the key can not leave the [KeyStore]'s hardware security module.
|
||||||
|
*/
|
||||||
|
fun getMainKey(): SecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class KeyManagerImpl : KeyManager {
|
internal class KeyManagerImpl(
|
||||||
|
private val keyStore: KeyStore
|
||||||
private val keyStore by lazy {
|
) : KeyManager {
|
||||||
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
|
|
||||||
load(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun storeBackupKey(seed: ByteArray) {
|
override fun storeBackupKey(seed: ByteArray) {
|
||||||
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
|
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
|
||||||
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
|
val backupKeyEntry =
|
||||||
val ksEntry = SecretKeyEntry(secretKeySpec)
|
SecretKeyEntry(SecretKeySpec(seed, 0, KEY_SIZE_BYTES, KEY_ALGORITHM_BACKUP))
|
||||||
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
|
keyStore.setEntry(KEY_ALIAS_BACKUP, backupKeyEntry, getBackupKeyProtection())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) &&
|
override fun storeMainKey(seed: ByteArray) {
|
||||||
keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java)
|
if (seed.size < KEY_SIZE_BYTES * 2) throw IllegalArgumentException()
|
||||||
|
val mainKeyEntry =
|
||||||
|
SecretKeyEntry(SecretKeySpec(seed, KEY_SIZE_BYTES, KEY_SIZE_BYTES, KEY_ALGORITHM_MAIN))
|
||||||
|
keyStore.setEntry(KEY_ALIAS_MAIN, mainKeyEntry, getMainKeyProtection())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS_BACKUP)
|
||||||
|
|
||||||
|
override fun hasMainKey() = keyStore.containsAlias(KEY_ALIAS_MAIN)
|
||||||
|
|
||||||
override fun getBackupKey(): SecretKey {
|
override fun getBackupKey(): SecretKey {
|
||||||
val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry
|
val ksEntry = keyStore.getEntry(KEY_ALIAS_BACKUP, null) as SecretKeyEntry
|
||||||
return ksEntry.secretKey
|
return ksEntry.secretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getKeyProtection(): KeyProtection {
|
override fun getMainKey(): SecretKey {
|
||||||
|
val ksEntry = keyStore.getEntry(KEY_ALIAS_MAIN, null) as SecretKeyEntry
|
||||||
|
return ksEntry.secretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBackupKeyProtection(): KeyProtection {
|
||||||
val builder = KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
|
val builder = KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
|
||||||
.setBlockModes(BLOCK_MODE_GCM)
|
.setBlockModes(BLOCK_MODE_GCM)
|
||||||
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
|
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
|
||||||
|
@ -70,4 +104,14 @@ internal class KeyManagerImpl : KeyManager {
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getMainKeyProtection(): KeyProtection {
|
||||||
|
// let's not lock down the main key too much, because we have no second chance
|
||||||
|
// and don't want to repeat the issue with the locked down backup key
|
||||||
|
val builder = KeyProtection.Builder(
|
||||||
|
PURPOSE_ENCRYPT or PURPOSE_DECRYPT or
|
||||||
|
PURPOSE_SIGN or PURPOSE_VERIFY
|
||||||
|
)
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,9 +70,12 @@ class RecoveryCodeViewModel(
|
||||||
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "")
|
||||||
if (forVerifyingNewCode) {
|
if (forVerifyingNewCode) {
|
||||||
keyManager.storeBackupKey(seed)
|
keyManager.storeBackupKey(seed)
|
||||||
|
keyManager.storeMainKey(seed)
|
||||||
mRecoveryCodeSaved.setEvent(true)
|
mRecoveryCodeSaved.setEvent(true)
|
||||||
} else {
|
} else {
|
||||||
mExistingCodeChecked.setEvent(crypto.verifyBackupKey(seed))
|
val verified = crypto.verifyBackupKey(seed)
|
||||||
|
if (verified && !keyManager.hasMainKey()) keyManager.storeMainKey(seed)
|
||||||
|
mExistingCodeChecked.setEvent(verified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,12 +15,24 @@ class KeyManagerTestImpl : KeyManager {
|
||||||
throw NotImplementedError("not implemented")
|
throw NotImplementedError("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun storeMainKey(seed: ByteArray) {
|
||||||
|
throw NotImplementedError("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override fun hasBackupKey(): Boolean {
|
override fun hasBackupKey(): Boolean {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun hasMainKey(): Boolean {
|
||||||
|
throw NotImplementedError("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override fun getBackupKey(): SecretKey {
|
override fun getBackupKey(): SecretKey {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getMainKey(): SecretKey {
|
||||||
|
throw NotImplementedError("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
45
app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
Normal file
45
app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import com.stevesoltys.seedvault.crypto.CipherFactory
|
||||||
|
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
|
||||||
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
|
import com.stevesoltys.seedvault.crypto.CryptoImpl
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
||||||
|
import com.stevesoltys.seedvault.header.headerModule
|
||||||
|
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
|
||||||
|
import com.stevesoltys.seedvault.restore.install.installModule
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.backupModule
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
class TestApp : App() {
|
||||||
|
|
||||||
|
private val testCryptoModule = module {
|
||||||
|
factory<CipherFactory> { CipherFactoryImpl(get()) }
|
||||||
|
single<KeyManager> { KeyManagerTestImpl() }
|
||||||
|
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
||||||
|
}
|
||||||
|
private val appModule = module {
|
||||||
|
single { Clock() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startKoin() = startKoin {
|
||||||
|
androidContext(this@TestApp)
|
||||||
|
modules(
|
||||||
|
listOf(
|
||||||
|
testCryptoModule,
|
||||||
|
headerModule,
|
||||||
|
metadataModule,
|
||||||
|
documentsProviderModule, // storage plugin
|
||||||
|
backupModule,
|
||||||
|
restoreModule,
|
||||||
|
installModule,
|
||||||
|
appModule
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.stevesoltys.seedvault.crypto
|
||||||
|
|
||||||
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import junit.framework.Assert.assertTrue
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
@TestInstance(PER_METHOD)
|
||||||
|
class KeyManagerImplTest {
|
||||||
|
|
||||||
|
private val keyStore: KeyStore = mockk()
|
||||||
|
private val keyManager = KeyManagerImpl(keyStore)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `31 byte seed gets rejected for backup key`() {
|
||||||
|
val seed = getRandomByteArray(31)
|
||||||
|
assertThrows<IllegalArgumentException> {
|
||||||
|
keyManager.storeBackupKey(seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `63 byte seed gets rejected for main key`() {
|
||||||
|
val seed = getRandomByteArray(63)
|
||||||
|
assertThrows<IllegalArgumentException> {
|
||||||
|
keyManager.storeMainKey(seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `32 byte seed gets accepted for backup key`() {
|
||||||
|
val seed = getRandomByteArray(32)
|
||||||
|
val keyEntry = slot<KeyStore.SecretKeyEntry>()
|
||||||
|
|
||||||
|
every { keyStore.setEntry(any(), capture(keyEntry), any()) } just Runs
|
||||||
|
|
||||||
|
keyManager.storeBackupKey(seed)
|
||||||
|
|
||||||
|
assertTrue(keyEntry.isCaptured)
|
||||||
|
assertArrayEquals(seed.sliceArray(0 until 32), keyEntry.captured.secretKey.encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `64 byte seed gets accepted for main key`() {
|
||||||
|
val seed = getRandomByteArray(64)
|
||||||
|
val keyEntry = slot<KeyStore.SecretKeyEntry>()
|
||||||
|
|
||||||
|
every { keyStore.setEntry(any(), capture(keyEntry), any()) } just Runs
|
||||||
|
|
||||||
|
keyManager.storeMainKey(seed)
|
||||||
|
|
||||||
|
assertTrue(keyEntry.isCaptured)
|
||||||
|
assertArrayEquals(seed.sliceArray(32 until 64), keyEntry.captured.secretKey.encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
|
import com.stevesoltys.seedvault.TestApp
|
||||||
import com.stevesoltys.seedvault.getRandomByteArray
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
|
@ -37,7 +38,10 @@ import kotlin.random.Random
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Config(sdk = [29]) // robolectric does not support 30, yet
|
@Config(
|
||||||
|
sdk = [29], // robolectric does not support 30, yet
|
||||||
|
application = TestApp::class
|
||||||
|
)
|
||||||
class MetadataManagerTest {
|
class MetadataManagerTest {
|
||||||
|
|
||||||
private val context: Context = mockk()
|
private val context: Context = mockk()
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.net.Uri
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.stevesoltys.seedvault.TestApp
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
@ -15,7 +16,10 @@ import org.koin.core.context.stopKoin
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Config(sdk = [29]) // robolectric does not support 30, yet
|
@Config(
|
||||||
|
sdk = [29], // robolectric does not support 30, yet
|
||||||
|
application = TestApp::class
|
||||||
|
)
|
||||||
internal class DocumentFileTest {
|
internal class DocumentFileTest {
|
||||||
|
|
||||||
private val context: Context = mockk()
|
private val context: Context = mockk()
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.util.DisplayMetrics
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.TestApp
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
@ -20,7 +21,10 @@ import org.robolectric.annotation.Config
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@Config(sdk = [29]) // robolectric does not support 30, yet
|
@Config(
|
||||||
|
sdk = [29], // robolectric does not support 30, yet
|
||||||
|
application = TestApp::class
|
||||||
|
)
|
||||||
internal class DeviceInfoTest {
|
internal class DeviceInfoTest {
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|
Loading…
Add table
Reference in a new issue