diff --git a/Cargo.lock b/Cargo.lock index e35064d08..6f6ab0378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2585,7 +2585,7 @@ dependencies = [ [[package]] name = "sargon" -version = "1.1.2" +version = "1.1.3" dependencies = [ "actix-rt", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index c6c2fed3d..2224ae958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sargon" -version = "1.1.2" +version = "1.1.3" edition = "2021" build = "build.rs" diff --git a/apple/Sources/Sargon/Drivers/HostInfo/HostInfoDriver+DeviceInfo.swift b/apple/Sources/Sargon/Drivers/HostInfo/HostInfoDriver+DeviceInfo.swift index 66ab4bc84..5bf0e951f 100644 --- a/apple/Sources/Sargon/Drivers/HostInfo/HostInfoDriver+DeviceInfo.swift +++ b/apple/Sources/Sargon/Drivers/HostInfo/HostInfoDriver+DeviceInfo.swift @@ -28,7 +28,7 @@ import UIKit extension AppleHostInfoDriver: HostInfoDriver { public func hostOs() async -> HostOs { - await .ios(version: UIDevice.current.systemVersion) + await HostOs.ios(version: UIDevice.current.systemVersion) } nonisolated public func hostDeviceName() async -> String { diff --git a/apple/Sources/Sargon/Drivers/ProfileChange/ProfileChange.swift b/apple/Sources/Sargon/Drivers/ProfileChange/ProfileChange.swift new file mode 100644 index 000000000..0b1d978b8 --- /dev/null +++ b/apple/Sources/Sargon/Drivers/ProfileChange/ProfileChange.swift @@ -0,0 +1,17 @@ +import SargonUniFFI + +public typealias ProfileChangeEventPublisher = EventPublisher + +extension ProfileChangeEventPublisher: ProfileChangeDriver { + public static let shared = ProfileChangeEventPublisher() + + public func handleProfileChange(changedProfile: Profile) async { + subject.send(changedProfile) + } +} + +extension ProfileChangeDriver where Self == ProfileChangeEventPublisher { + public static var shared: Self { + ProfileChangeEventPublisher.shared + } +} diff --git a/apple/Sources/Sargon/Extensions/Swiftified/System/Drivers/Drivers+Swiftified.swift b/apple/Sources/Sargon/Extensions/Swiftified/System/Drivers/Drivers+Swiftified.swift index 6c12af031..27d11d164 100644 --- a/apple/Sources/Sargon/Extensions/Swiftified/System/Drivers/Drivers+Swiftified.swift +++ b/apple/Sources/Sargon/Extensions/Swiftified/System/Drivers/Drivers+Swiftified.swift @@ -55,7 +55,8 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: .shared, - unsafeStorage: unsafeStorage + unsafeStorage: unsafeStorage, + profileChangeDriver: .shared ) } } diff --git a/apple/Sources/Sargon/Extensions/Swiftified/System/SargonOS+Swiftified.swift b/apple/Sources/Sargon/Extensions/Swiftified/System/SargonOS+Swiftified.swift index c88e86ac0..2c70a82af 100644 --- a/apple/Sources/Sargon/Extensions/Swiftified/System/SargonOS+Swiftified.swift +++ b/apple/Sources/Sargon/Extensions/Swiftified/System/SargonOS+Swiftified.swift @@ -12,11 +12,3 @@ import SargonUniFFI public typealias SargonOS = SargonOs extension SargonOS: @unchecked Sendable {} - -extension SargonOS { - - @available(*, deprecated, message: "SHOULD migrate to use more specialized methods on SargonOS instead, e.g. `createAndSaveNewAccount` - SargonOS should be the SOLE object to perform the mutation and persisting.") - public func saveChangedProfile(_ profile: Profile) async throws { - try await deprecatedSaveFfiChangedProfile(profile: profile) - } -} diff --git a/apple/Sources/Sargon/SargonOS/SargonOSProtocol.swift b/apple/Sources/Sargon/SargonOS/SargonOSProtocol.swift index 751745782..9d4d48bfe 100644 --- a/apple/Sources/Sargon/SargonOS/SargonOSProtocol.swift +++ b/apple/Sources/Sargon/SargonOS/SargonOSProtocol.swift @@ -29,12 +29,6 @@ extension SargonOSProtocol { // MARK: Extensions extension SargonOSProtocol { - - @available(*, deprecated, message: "SHOULD migrate to use more specialized access methods on SargonOS instead, e.g. `accountsOnCurrentNetwork`.") - public var profile: Profile { - os.profile() - } - public var currentNetworkID: NetworkID { os.currentNetworkId() } diff --git a/apple/Sources/Sargon/Util/EventPublisher.swift b/apple/Sources/Sargon/Util/EventPublisher.swift new file mode 100644 index 000000000..25d78832a --- /dev/null +++ b/apple/Sources/Sargon/Util/EventPublisher.swift @@ -0,0 +1,15 @@ +import AsyncExtensions + +public final actor EventPublisher { + public typealias Subject = AsyncPassthroughSubject + public typealias Stream = AsyncThrowingPassthroughSubject + + let stream = Stream() + let subject = Subject() + + public func eventStream() -> AsyncMulticastSequence { + subject + .multicast(stream) + .autoconnect() + } +} diff --git a/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift b/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift index 7a895cc48..956562794 100644 --- a/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift +++ b/apple/Tests/IntegrationTests/DriversTests/EventBusDriverTests.swift @@ -54,7 +54,8 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: .shared, - unsafeStorage: .shared + unsafeStorage: .shared, + profileChangeDriver: .shared ) } @@ -67,8 +68,9 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: .shared, - unsafeStorage: .shared - + unsafeStorage: .shared, + profileChangeDriver: .shared + ) } @@ -81,7 +83,8 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: .shared, - unsafeStorage: .shared + unsafeStorage: .shared, + profileChangeDriver: .shared ) } @@ -94,7 +97,8 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: .shared, - unsafeStorage: .shared + unsafeStorage: .shared, + profileChangeDriver: .shared ) } @@ -107,7 +111,8 @@ extension Drivers { logging: logging, eventBus: .shared, fileSystem: .shared, - unsafeStorage: .shared + unsafeStorage: .shared, + profileChangeDriver: .shared ) } @@ -120,7 +125,8 @@ extension Drivers { logging: .shared, eventBus: eventBus, fileSystem: .shared, - unsafeStorage: .shared + unsafeStorage: .shared, + profileChangeDriver: .shared ) } @@ -133,7 +139,8 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: fileSystem, - unsafeStorage: .shared + unsafeStorage: .shared, + profileChangeDriver: .shared ) } @@ -146,7 +153,8 @@ extension Drivers { logging: .shared, eventBus: .shared, fileSystem: .shared, - unsafeStorage: unsafeStorage + unsafeStorage: unsafeStorage, + profileChangeDriver: .shared ) } } diff --git a/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift b/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift index dc7bffc99..0df0b7101 100644 --- a/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift +++ b/apple/Tests/IntegrationTests/DriversTests/InsecureStorageTests.swift @@ -9,7 +9,7 @@ class InsecureStorageDriverTests: DriverTest { let sut = SUT.shared as NetworkingDriver let response = try await sut.executeNetworkRequest( request: .init( - validating: "https://radixdlt.com", - method: .head + validating: "https://stokenet.radixdlt.com/", + method: .get ) ) XCTAssertEqual(response.statusCode, 200) diff --git a/apple/Tests/IntegrationTests/SargonOS/TestOSTests.swift b/apple/Tests/IntegrationTests/SargonOS/TestOSTests.swift index ecf8603c1..30404562c 100644 --- a/apple/Tests/IntegrationTests/SargonOS/TestOSTests.swift +++ b/apple/Tests/IntegrationTests/SargonOS/TestOSTests.swift @@ -23,7 +23,8 @@ extension TestOS { userDefaults: .init( suiteName: UUID().uuidString )! - ) + ), + profileChangeDriver: .shared ) ) ) @@ -85,10 +86,10 @@ final class TestOSTests: OSTest { func test_if_replace_profile_throws() async throws { let sut = try await TestOS() - var profile = sut.profile + var profile = sut.os.profile() profile.header.id = ProfileID() // mutate profile do { - try await sut.os.saveChangedProfile(profile) + try await sut.os.setProfile(profile: profile) XCTFail("We expected to throw") } catch { /* We expected to throw */ @@ -97,13 +98,13 @@ final class TestOSTests: OSTest { func test_we_can_mutate_profile_in_swift_and_save_then_profile_is_updated() async throws { let sut = try await TestOS() - var profile = sut.profile + var profile = sut.os.profile() let creatingDevice = profile.header.creatingDevice let newCreatingDevice = DeviceInfo.sampleOther XCTAssertNotEqual(newCreatingDevice, creatingDevice) profile.header.creatingDevice = newCreatingDevice // mutate profile - try await sut.os.saveChangedProfile(profile) - XCTAssertEqual(sut.profile.header.creatingDevice, newCreatingDevice) // assert change worked + try await sut.os.setProfile(profile: profile) + XCTAssertEqual(sut.os.profile().header.creatingDevice, newCreatingDevice) // assert change worked } func test_batch_create_many_accounts() async throws { diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index 7ed3c30b6..b6e9275c0 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) } android { @@ -38,6 +40,7 @@ android { jvmTarget = "1.8" } buildFeatures { + buildConfig = true compose = true } composeOptions { @@ -56,6 +59,17 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.hilt.android) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.activity) + ksp(libs.hilt.compiler) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml index eacbac418..9dd331160 100644 --- a/examples/android/src/main/AndroidManifest.xml +++ b/examples/android/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - + diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/EphemeralKeystore.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/EphemeralKeystore.kt deleted file mode 100644 index 681b077f6..000000000 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/EphemeralKeystore.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.radixdlt.sargon.android - -import com.radixdlt.sargon.BagOfBytes -import com.radixdlt.sargon.SecureStorageDriver -import com.radixdlt.sargon.SecureStorageKey -import com.radixdlt.sargon.extensions.identifier - -class EphemeralKeystore: SecureStorageDriver { - private val storage: MutableMap = mutableMapOf() - - override suspend fun loadData(key: SecureStorageKey): BagOfBytes? = storage[key.identifier] - - override suspend fun saveData(key: SecureStorageKey, data: BagOfBytes) { - storage[key.identifier] = data - } - - override suspend fun deleteDataForKey(key: SecureStorageKey) { - storage.remove(key = key.identifier) - } - - fun isEmpty() = storage.isEmpty() - -// fun contains(value: String): Boolean { -// return storage.any { entry -> -// entry.value().decodeToString().contains(value) -// } -// } -// -// override fun toString(): String { -// return storage.toList().joinToString(prefix = "[", postfix = "\n]") { pair -> -// "\n\t${pair.first} => ${pair.second.decodeToString()}" -// } -// } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EphemeralKeystore - - return storage == other.storage - } - - override fun hashCode(): Int { - return storage.hashCode() - } - - -} \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt new file mode 100644 index 000000000..e43163ac0 --- /dev/null +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/ExampleApplication.kt @@ -0,0 +1,7 @@ +package com.radixdlt.sargon.android + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ExampleApplication: Application() \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt index 8886f668f..3a9d0a5d9 100644 --- a/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/MainActivity.kt @@ -3,188 +3,81 @@ package com.radixdlt.sargon.android import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.radixdlt.sargon.DisplayName -import com.radixdlt.sargon.NetworkId -import com.radixdlt.sargon.NonEmptyMax32Bytes -import com.radixdlt.sargon.Profile -import com.radixdlt.sargon.ProfileNetwork -import com.radixdlt.sargon.SecureStorageDriver +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.radixdlt.sargon.android.ui.theme.SargonAndroidTheme -import com.radixdlt.sargon.annotation.UsesSampleValues -import com.radixdlt.sargon.extensions.toBagOfBytes -import com.radixdlt.sargon.samples.sample -import kotlin.random.Random +import com.radixdlt.sargon.extensions.errorMessage +import com.radixdlt.sargon.os.driver.BiometricsHandler +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject -class MainActivity : ComponentActivity() { +@AndroidEntryPoint +class MainActivity : FragmentActivity() { + + private val viewModel: MainViewModel by viewModels() + + @Inject + lateinit var biometricsHandler: BiometricsHandler override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val storage = EphemeralKeystore() + biometricsHandler.register(this) - setContent { SargonAndroidTheme { WalletContent(storage = storage) } } + setContent { + SargonAndroidTheme { + WalletContent(viewModel = viewModel) + } + } } } @Composable -fun WalletContent(modifier: Modifier = Modifier, storage: SecureStorageDriver) { -// var walletState: Wallet? by remember { mutableStateOf(null) } -// var profile: Profile? by remember { mutableStateOf(null) } -// -// Scaffold( -// modifier = modifier, -// topBar = { TopAppBar(title = { Text(text = "Wallet Test") }) }, -// bottomBar = { -// if (walletState == null) { -// Button( -// modifier = Modifier -// .padding(16.dp) -// .fillMaxWidth(), -// onClick = { -// walletState = -// Wallet.with( -// entropy = ByteArray(32) { 0xFF.toByte() }, -// secureStorage = storage -// ) -// profile = walletState?.profile() -// } -// ) { Text(text = "Generate new Wallet") } -// } else if (profile?.networks?.isEmpty() == true) { -// Column(modifier = Modifier.padding(16.dp)) { -// var accountName by remember { mutableStateOf("") } -// TextField( -// modifier = Modifier.fillMaxWidth(), -// value = accountName, -// onValueChange = { accountName = it }, -// label = { Text(text = "New Account Name") }, -// singleLine = true, -// keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), -// keyboardActions = -// KeyboardActions( -// onDone = { -// walletState?.createAndSaveNewAccount( -// networkId = NetworkId.MAINNET, -// name = DisplayName(accountName) -// ) -// -// profile = walletState?.profile() -// } -// ) -// ) -// } -// } -// } -// ) { padding -> -// LazyColumn(modifier = Modifier.padding(padding), contentPadding = PaddingValues(16.dp)) { -// items(profile?.networks.orEmpty()) { -// Network( -// network = it, -// onAccountAdd = { newName -> -// walletState?.createNewAccount(NetworkId.MAINNET, DisplayName(newName)) -// ?.let { -// walletState?.addAccount(it) -// -// profile = walletState?.profile() -// } -// } -// ) -// } -// } -// } -} - -@Composable -fun Network( +fun WalletContent( modifier: Modifier = Modifier, - network: ProfileNetwork, - onAccountAdd: (String) -> Unit + viewModel: MainViewModel ) { - ElevatedCard(modifier = modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.height(16.dp)) - Text(modifier = Modifier.padding(horizontal = 16.dp), text = "${network.id}") + val state: MainViewModel.State by viewModel.state.collectAsStateWithLifecycle() -// network.accounts.forEach { account -> -// Text( -// modifier = Modifier.padding(horizontal = 32.dp), -// text = account.displayName.value, -// style = MaterialTheme.typography.labelLarge -// ) -// Text( -// modifier = Modifier.padding(horizontal = 32.dp), -// text = account.address.string, -// style = MaterialTheme.typography.labelSmall -// ) -// HorizontalDivider(modifier = Modifier.padding(horizontal = 32.dp)) -// } + val context = LocalContext.current + Scaffold( + modifier = modifier, + topBar = { TopAppBar(title = { Text(text = "Sargon Os") }) }, + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + when (val sargonState = state.sargonState) { + SargonOsManager.SargonState.Idle -> { + Text(text = "OS is idle") + } + is SargonOsManager.SargonState.Booted -> { + Text(text = "OS Booted!") + } + is SargonOsManager.SargonState.BootError -> { + Text(text = "Os Boot Error") + Text(text = sargonState.error.errorMessage) - Column(modifier = Modifier.padding(16.dp)) { - var newAccountName by remember { mutableStateOf("") } - TextField( - modifier = Modifier.fillMaxWidth(), - value = newAccountName, - onValueChange = { newAccountName = it }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { - onAccountAdd(newAccountName) - newAccountName = "" - } - ) - ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { viewModel.retryBooting() } + ) { + Text(text = "Retry biometrics") + } + } + } } } -} - -//val Wallet.Companion.defaultPhoneName: String -// get() = "Android Phone" -// -//fun Wallet.Companion.with( -// entropy: ByteArray = ByteArray(32).apply { Random.nextBytes(this) }, -// phoneName: String = Wallet.Companion.defaultPhoneName, -// secureStorage: SecureStorageDriver -//): Wallet { -// return Wallet.byCreatingNewProfileAndSecretsWithEntropy( -// entropy = NonEmptyMax32Bytes(entropy.toBagOfBytes()), -// walletClientModel = WalletClientModel.ANDROID, -// walletClientName = phoneName, -// secureStorage = secureStorage -// ) -//} - -@OptIn(UsesSampleValues::class) -@Preview(showBackground = true) -@Composable -fun NetworkPreview() { - val profile = Profile.sample() - Network(network = profile.networks.first(), onAccountAdd = {}) -} +} \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt new file mode 100644 index 000000000..caf218be9 --- /dev/null +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/MainViewModel.kt @@ -0,0 +1,39 @@ +package com.radixdlt.sargon.android + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.radixdlt.sargon.Account +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val sargonOsManager: SargonOsManager +) : ViewModel() { + + val state = sargonOsManager.sargonState + .map { + State(sargonState = it) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = State() + ) + + fun retryBooting() { + viewModelScope.launch { + sargonOsManager.boot() + } + } + + data class State( + val sargonState: SargonOsManager.SargonState = SargonOsManager.SargonState.Idle, + val accounts: List = emptyList() + ) + +} \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/SargonOsManager.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/SargonOsManager.kt new file mode 100644 index 000000000..b60f4f046 --- /dev/null +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/SargonOsManager.kt @@ -0,0 +1,70 @@ +package com.radixdlt.sargon.android + +import com.radixdlt.sargon.Bios +import com.radixdlt.sargon.CommonException +import com.radixdlt.sargon.SargonOs +import com.radixdlt.sargon.android.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SargonOsManager @Inject constructor( + private val bios: Bios, + @ApplicationScope private val applicationScope: CoroutineScope +) { + + private val _state = MutableStateFlow(SargonState.Idle) + + val sargonState: Flow + get() = _state.asStateFlow() + + val sargon: Flow = _state + .filterIsInstance() + .map { it.os } + + + init { + boot() + } + + fun boot() = applicationScope.launch { + if (_state.value is SargonState.Booted) { + return@launch + } + + withContext(Dispatchers.Default) { + runCatching { + SargonOs.boot(bios) + }.onSuccess { os -> + _state.update { SargonState.Booted(os) } + }.onFailure { error -> + if (error is CommonException) { + _state.update { SargonState.BootError(error) } + } else { + throw error + } + } + } + } + + sealed interface SargonState { + data object Idle: SargonState + data class BootError( + val error: CommonException + ): SargonState + data class Booted( + val os: SargonOs + ): SargonState + } + +} \ No newline at end of file diff --git a/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt b/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt new file mode 100644 index 000000000..39b7c014b --- /dev/null +++ b/examples/android/src/main/java/com/radixdlt/sargon/android/di/ApplicationModule.kt @@ -0,0 +1,148 @@ +package com.radixdlt.sargon.android.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.radixdlt.sargon.Bios +import com.radixdlt.sargon.android.BuildConfig +import com.radixdlt.sargon.os.driver.BiometricsHandler +import com.radixdlt.sargon.os.from +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import javax.inject.Qualifier +import javax.inject.Singleton + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class EncryptedPreferences + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class NonEncryptedPreferences + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DeviceInfoPreferences + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object ApplicationModule { + + private val Context.preferencesDatastore: DataStore by preferencesDataStore( + name = "example_preferences" + ) + + private val Context.encryptedPreferencesDatastore: DataStore by preferencesDataStore( + name = "example_encrypted_preferences" + ) + + private val Context.deviceInfoPreferencesDatastore: DataStore by preferencesDataStore( + name = "example_device_info_preferences" + ) + + @DefaultDispatcher + @Provides + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @IoDispatcher + @Provides + fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Singleton + @ApplicationScope + @Provides + fun providesCoroutineScope( + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher + ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher) + + + @Provides + @Singleton + fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + } + + @Provides + @Singleton + fun provideGatewayHttpClient( + httpLoggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(httpLoggingInterceptor) + .build() + } + + @Provides + @Singleton + @NonEncryptedPreferences + fun providePreferences( + @ApplicationContext context: Context + ): DataStore = context.preferencesDatastore + + @Provides + @Singleton + @EncryptedPreferences + fun provideEncryptedPreferences( + @ApplicationContext context: Context + ): DataStore = context.encryptedPreferencesDatastore + + @Provides + @Singleton + @DeviceInfoPreferences + fun provideDeviceInfoPreferences( + @ApplicationContext context: Context + ): DataStore = context.deviceInfoPreferencesDatastore + + @Provides + @Singleton + fun provideBiometricsHandler(): BiometricsHandler = BiometricsHandler( + biometricsSystemDialogTitle = "Authenticate to continue" + ) + + @Provides + @Singleton + fun provideBios( + @ApplicationContext context: Context, + httpClient: OkHttpClient, + biometricsHandler: BiometricsHandler, + @EncryptedPreferences encryptedPreferences: DataStore, + @NonEncryptedPreferences preferences: DataStore, + @DeviceInfoPreferences deviceInfoPreferences: DataStore, + ): Bios = Bios.from( + context = context, + enableLogging = BuildConfig.DEBUG, + httpClient = httpClient, + biometricsHandler = biometricsHandler, + encryptedPreferencesDataStore = encryptedPreferences, + preferencesDatastore = preferences, + deviceInfoDatastore = deviceInfoPreferences + ) +} \ No newline at end of file diff --git a/jvm/build.gradle.kts b/jvm/build.gradle.kts index 7e941195b..4096c2519 100644 --- a/jvm/build.gradle.kts +++ b/jvm/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false alias(libs.plugins.android.cargo.ndk) apply false alias(libs.plugins.kotlin.kover) apply false } \ No newline at end of file diff --git a/jvm/gradle/libs.versions.toml b/jvm/gradle/libs.versions.toml index dc191bde7..e4accbfa1 100644 --- a/jvm/gradle/libs.versions.toml +++ b/jvm/gradle/libs.versions.toml @@ -1,44 +1,67 @@ [versions] activity-compose = "1.7.0" -lifecycle-runtime-ktx = "2.6.1" +lifecycle = "2.6.1" sdk-min = "27" sdk-target = "34" sdk-compile = "34" appcompat = "1.6.1" core-ktx = "1.12.0" +biometric = "1.2.0-alpha05" junit = "5.10.2" mockk = "1.13.10" kotlin = "1.9.22" agp = "8.2.2" material = "1.11.0" +timber = "5.0.1" cargo-ndk = "0.3.4" compose-bom = "2024.02.00" kover = "0.7.6" datastore = "1.1.1" coroutines = "1.8.0" +okhttp = "5.0.0-alpha.14" +turbine = "1.1.0" +hilt = "2.51.1" +ksp = "1.9.22-1.0.17" +viewmodel = "2.7.0" androidx-test = "1.5.0" androidx-test-junit = "1.1.5" +activity = "1.9.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } -androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometric" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "viewmodel" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "viewmodel" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "viewmodel" } material = { module = "com.google.android.material:material", version.ref = "material" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines", version.ref = "okhttp" } +okhttp-mock-web-server = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } junit = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } androidx-test-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-junit" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -47,4 +70,6 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-cargo-ndk = { id = "com.github.willir.rust.cargo-ndk-android", version.ref = "cargo-ndk" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } \ No newline at end of file diff --git a/jvm/sargon-android/build.gradle.kts b/jvm/sargon-android/build.gradle.kts index d038909d2..31f3ccb10 100644 --- a/jvm/sargon-android/build.gradle.kts +++ b/jvm/sargon-android/build.gradle.kts @@ -46,6 +46,7 @@ android { java.srcDir("${buildDir}/generated/src/release/java") } } + packaging { resources.excludes.add("META-INF/*") } } cargoNdk { @@ -68,6 +69,8 @@ koverReport { filters { excludes { packages("com.radixdlt.sargon.samples") + // Drivers are tested in androidTest + packages("com.radixdlt.sargon.os.driver") annotatedBy("com.radixdlt.sargon.annotation.KoverIgnore") } includes { @@ -89,6 +92,12 @@ dependencies { // the jna dependency cannot be resolved implementation("net.java.dev.jna:jna:5.13.0@aar") + // For lifecycle callbacks + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.runtime.ktx) + // For biometric requests for secure storage + implementation(libs.androidx.biometric.ktx) + // For Coroutines support implementation(libs.coroutines.android) @@ -96,18 +105,21 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") // For Network support - implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.12")) - implementation("com.squareup.okhttp3:okhttp") - implementation("com.squareup.okhttp3:okhttp-coroutines") + implementation(libs.okhttp) + implementation(libs.okhttp.coroutines) // For Storage implementation implementation(libs.androidx.datastore.preferences) + // For logging + implementation(libs.timber) + // Unit tests testImplementation(libs.junit) testImplementation(libs.junit.params) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) + testImplementation(libs.turbine) testRuntimeOnly("org.junit.platform:junit-platform-launcher") testDebugRuntimeOnly(project(":sargon-desktop-debug")) testReleaseRuntimeOnly(project(":sargon-desktop-release")) @@ -117,6 +129,9 @@ dependencies { androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.junit.ktx) androidTestImplementation(libs.coroutines.test) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.mockk.agent) + androidTestImplementation(libs.okhttp.mock.web.server) } publishing { diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidFileSystemDriverTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidFileSystemDriverTest.kt new file mode 100644 index 000000000..77621bdc7 --- /dev/null +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidFileSystemDriverTest.kt @@ -0,0 +1,137 @@ +package com.radixdlt.sargon.os.driver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.radixdlt.sargon.extensions.bagOfBytes +import com.radixdlt.sargon.extensions.string +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import timber.log.Timber +import java.io.File + +@RunWith(AndroidJUnit4::class) +@SmallTest +class AndroidFileSystemDriverTest { + + @Test + fun given_no_file_when_save_invoked_then_file_is_created() = runTest { + val sut = sut() + + sut.saveToFile(TEMP_FILE_NAME, bagOfBytes(PAYLOAD)) + + val retrievedData = sut.loadFromFile(TEMP_FILE_NAME) + assertEquals( + PAYLOAD, + retrievedData?.string + ) + } + + @Test + fun given_file_exists_when_save_invoked_then_file_contents_are_replaced() = runTest { + val sut = sut() + + sut.saveToFile(TEMP_FILE_NAME, bagOfBytes(PAYLOAD)) + sut.saveToFile(TEMP_FILE_NAME, bagOfBytes("Michael")) + + val retrievedData = sut.loadFromFile(TEMP_FILE_NAME) + assertEquals( + "Michael", + retrievedData?.string + ) + } + + @Test + fun given_file_exists_when_delete_invoked_then_file_deleted() = runTest { + val sut = sut() + + sut.saveToFile(TEMP_FILE_NAME, bagOfBytes(PAYLOAD)) + // Ensure file exists prior to delete + assertTrue( + File( + sargonDir, + TEMP_FILE_NAME + ).exists() + ) + + sut.deleteFile(TEMP_FILE_NAME) + + assertFalse( + File( + sargonDir, + TEMP_FILE_NAME + ).exists() + ) + } + + @Test + fun given_no_file_exists_when_delete_invoked_then_file_remains_non_existent() = runTest { + val sut = sut() + + // Ensure file does not exist prior to delete + assertFalse( + File( + sargonDir, + TEMP_FILE_NAME + ).exists() + ) + + sut.deleteFile(TEMP_FILE_NAME) + + assertFalse( + File( + sargonDir, + TEMP_FILE_NAME + ).exists() + ) + } + + @Test + fun given_file_deleted_when_read_invoked_then_null_returned() = runTest { + val sut = sut() + sut.saveToFile(TEMP_FILE_NAME, bagOfBytes(PAYLOAD)) + // Ensure file exists prior to delete + assertTrue( + File( + sargonDir, + TEMP_FILE_NAME + ).exists() + ) + + sut.deleteFile(TEMP_FILE_NAME) + + assertNull( + sut.loadFromFile(TEMP_FILE_NAME) + ) + } + + @After + fun clean() { + sargonDir.deleteRecursively() + } + + private fun TestScope.sut() = AndroidFileSystemDriver( + context = InstrumentationRegistry.getInstrumentation().context, + dispatcher = StandardTestDispatcher(testScheduler) + ).also { + Timber.plant(Timber.DebugTree()) + } + + companion object { + private val sargonDir = File( + InstrumentationRegistry.getInstrumentation().context.filesDir, + AndroidFileSystemDriver.BASE_DIR + ) + + private const val TEMP_FILE_NAME = "file.txt" + private const val PAYLOAD = "The quick brown fox jumps over the lazy dog" + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidHostInfoDriverTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidHostInfoDriverTest.kt new file mode 100644 index 000000000..a40c2de2c --- /dev/null +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidHostInfoDriverTest.kt @@ -0,0 +1,75 @@ +package com.radixdlt.sargon.os.driver + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.radixdlt.sargon.HostOs +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SmallTest +class AndroidHostInfoDriverTest { + + val sut = AndroidHostInfoDriver( + context = InstrumentationRegistry.getInstrumentation().context + ) + + @Suppress("DEPRECATION") + @Test + fun test_host_os() = runTest { + val hostOs = sut.hostOs() as HostOs.Android + + assertEquals( + Build.MANUFACTURER.capitalize(), + hostOs.vendor + ) + assertEquals( + "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + hostOs.version + ) + } + + @Test + fun test_app_version_instrumentation() = runTest { + // App version when this library is tested in a standalone manner should be empty + assertEquals( + "", + sut.hostAppVersion() + ) + } + + @Test + fun test_app_version_in_app() = runTest { + val testPackage = "com.sargon.android.test" + val testVersion = "1.0.0" + val packageManager = mockk().apply { + every { getPackageInfo(testPackage, 0) } returns PackageInfo().apply { + versionName = testVersion + } + } + val applicationContext = mockk().apply { + every { packageName } returns testPackage + every { this@apply.packageManager } returns packageManager + } + val context = mockk().apply { + every { this@apply.applicationContext } returns applicationContext + } + + + val sut = AndroidHostInfoDriver(context = context) + + assertEquals( + testVersion, + sut.hostAppVersion() + ) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidNetworkingDriverTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidNetworkingDriverTest.kt new file mode 100644 index 000000000..37320313e --- /dev/null +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidNetworkingDriverTest.kt @@ -0,0 +1,103 @@ +package com.radixdlt.sargon.os.driver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.radixdlt.sargon.NetworkMethod +import com.radixdlt.sargon.NetworkRequest +import com.radixdlt.sargon.extensions.bagOfBytes +import com.radixdlt.sargon.extensions.toBagOfBytes +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SmallTest +class AndroidNetworkingDriverTest { + + private val httpClient = OkHttpClient() + private val sut = AndroidNetworkingDriver(client = httpClient) + + @Test + fun test() = runMockWebServer { server -> + val requestBody = RequestBody( + id = 10, + message = "Hello World!" + ) + val responseBody = ResponseBody( + id = requestBody.id, + message = requestBody.message + ) + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody(Json.encodeToString(responseBody)) + ) + + val response = sut.executeNetworkRequest( + NetworkRequest( + url = server.url("/some/api"), + method = NetworkMethod.POST, + headers = mapOf( + "Content-Type" to "application/json" + ), + body = bagOfBytes(Json.encodeToString(requestBody)) + ) + ) + + // Request Assertions + val request = server.takeRequest() + assertEquals( + server.url("/some/api"), + request.requestUrl + ) + assertEquals( + "POST", + request.method + ) + assertEquals( + "application/json", + request.headers["Content-Type"] + ) + assertEquals( + bagOfBytes(Json.encodeToString(requestBody)), + request.body.readByteArray().toBagOfBytes() + ) + + // Response Assertions + assertEquals( + 200, + response.statusCode.toInt() + ) + assertEquals( + bagOfBytes(Json.encodeToString(responseBody)), + response.body + ) + } + + @Serializable + data class RequestBody( + val id: Int, + val message: String + ) + + @Serializable + data class ResponseBody( + val id: Int, + val message: String + ) + + private fun runMockWebServer(test: suspend (MockWebServer) -> Unit) = runTest { + val server = MockWebServer().apply { start() } + + test(server) + + server.shutdown() + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt new file mode 100644 index 000000000..f3b2a8c85 --- /dev/null +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverDeviceFactorSourceMnemonicTest.kt @@ -0,0 +1,166 @@ +package com.radixdlt.sargon.os.driver + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.radixdlt.sargon.CommonException +import com.radixdlt.sargon.Exactly32Bytes +import com.radixdlt.sargon.FactorSourceIdFromHash +import com.radixdlt.sargon.FactorSourceKind +import com.radixdlt.sargon.MnemonicWithPassphrase +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.mnemonicWithPassphraseToJsonBytes +import com.radixdlt.sargon.newMnemonicWithPassphraseFromJsonBytes +import com.radixdlt.sargon.os.driver.AndroidStorageDriverTest.Companion.sut +import com.radixdlt.sargon.os.driver.BiometricsFailure.AuthenticationNotPossible +import com.radixdlt.sargon.os.storage.EncryptionHelper +import com.radixdlt.sargon.samples.sample +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +@MediumTest +class AndroidStorageDriverDeviceFactorSourceMnemonicTest { + + private val testContext: Context = ApplicationProvider.getApplicationContext() + + @After + fun deleteDatastores() { + File(testContext.filesDir, "datastore").deleteRecursively() + mockUnauthorize() + } + + @Test + fun testWriteWithNoAuthorization() = runTest { + val sut = sut( + context = testContext, + scope = backgroundScope, + onAuthorize = { + Result.failure(AuthenticationNotPossible(BiometricManager.BIOMETRIC_STATUS_UNKNOWN)) + } + ) + + val id = FactorSourceIdFromHash( + kind = FactorSourceKind.DEVICE, + body = Exactly32Bytes.sample() + ) + val mnemonic = MnemonicWithPassphrase.sample() + + runCatching { + sut.saveData( + SecureStorageKey.DeviceFactorSourceMnemonic(id), + mnemonicWithPassphraseToJsonBytes(mnemonic) + ) + }.onFailure { error -> + assertTrue( + "Expected CommonException.SecureStorageWriteException but got $error", + error is CommonException.SecureStorageWriteException + ) + }.onSuccess { + error("Save operation did not throw when it should.") + } + + } + + @Test + fun testWriteWithAuthorization() = runTest { + val sut = sut( + context = testContext, + scope = backgroundScope, + onAuthorize = { + mockAuthorize() + Result.success(Unit) + } + ) + + val id = FactorSourceIdFromHash( + kind = FactorSourceKind.DEVICE, + body = Exactly32Bytes.sample() + ) + val mnemonic = MnemonicWithPassphrase.sample() + + sut.saveData( + SecureStorageKey.DeviceFactorSourceMnemonic(id), + mnemonicWithPassphraseToJsonBytes(mnemonic) + ) + + val retrievedMnemonic = sut.loadData(SecureStorageKey.DeviceFactorSourceMnemonic(id))?.let { + newMnemonicWithPassphraseFromJsonBytes(it) + } + + assertEquals( + mnemonic, + retrievedMnemonic + ) + } + + @Test + fun testRemove() = runTest { + var shouldAuthorize: Boolean = false + val sut = sut( + context = testContext, + scope = backgroundScope, + onAuthorize = { + if (shouldAuthorize) { + mockAuthorize() + Result.success(Unit) + } else { + mockUnauthorize() + Result.failure(AuthenticationNotPossible(BiometricManager.BIOMETRIC_STATUS_UNKNOWN)) + } + } + ) + + val id = FactorSourceIdFromHash( + kind = FactorSourceKind.DEVICE, + body = Exactly32Bytes.sample() + ) + val mnemonic = MnemonicWithPassphrase.sample() + + shouldAuthorize = true + sut.saveData( + SecureStorageKey.DeviceFactorSourceMnemonic(id), + mnemonicWithPassphraseToJsonBytes(mnemonic) + ) + + // No need to authorize biometrics in order to remove a mnemonic + shouldAuthorize = false + sut.deleteDataForKey(SecureStorageKey.DeviceFactorSourceMnemonic(id)) + + // Needs to authorize since, even though data is null. We just guard read access + shouldAuthorize = true + assertNull( + sut.loadData(SecureStorageKey.DeviceFactorSourceMnemonic(id)) + ) + } + + + private fun mockAuthorize() { + mockkObject(EncryptionHelper).apply { + val inputToEncryptSlot = slot() + every { EncryptionHelper.encrypt(capture(inputToEncryptSlot), any()) } answers { + Result.success(inputToEncryptSlot.captured) + } + val inputToDecryptSlot = slot() + every { EncryptionHelper.decrypt(capture(inputToDecryptSlot), any()) } answers { + Result.success(inputToDecryptSlot.captured) + } + } + } + + private fun mockUnauthorize() { + unmockkObject(EncryptionHelper) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverHostIdTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverHostIdTest.kt new file mode 100644 index 000000000..6df86a286 --- /dev/null +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverHostIdTest.kt @@ -0,0 +1,189 @@ +package com.radixdlt.sargon.os.driver + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.radixdlt.sargon.HostId +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.Timestamp +import com.radixdlt.sargon.Uuid +import com.radixdlt.sargon.hostIdToJsonBytes +import com.radixdlt.sargon.newHostIdFromJsonBytes +import com.radixdlt.sargon.os.driver.AndroidStorageDriverTest.Companion.OLD_DEVICE_INFO_PREFERENCES +import com.radixdlt.sargon.os.driver.AndroidStorageDriverTest.Companion.sut +import com.radixdlt.sargon.os.storage.key.HostIdAndroidEntry +import com.radixdlt.sargon.samples.sample +import com.radixdlt.sargon.serializer.TimestampSerializer +import com.radixdlt.sargon.serializer.UuidSerializer +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +@SmallTest +class AndroidStorageDriverHostIdTest { + + private val testContext: Context = ApplicationProvider.getApplicationContext() + + @After + fun deleteDatastores() { + File(testContext.filesDir, "datastore").deleteRecursively() + testContext.getSharedPreferences( + OLD_DEVICE_INFO_PREFERENCES, + Context.MODE_PRIVATE + ).edit().clear().commit() + } + + @Test + fun testDeviceInfoMigratedDirectlyToDatastore() = runTest { + // Setup device id in preferences + val prefEntry = OldDeviceInfoEntry.random() + val oldSharedPreferences = testContext.getSharedPreferences( + OLD_DEVICE_INFO_PREFERENCES, + Context.MODE_PRIVATE + ) + // store info in old preferences + oldSharedPreferences + .edit() + .putString("key_device_info", Json.encodeToString(prefEntry)) + .commit() + // Assert that info is stored in old preferences at this point + with( + oldSharedPreferences.getString("key_device_info", null)?.let { + Json.decodeFromString(it) + } + ) { + assertEquals(prefEntry.id, this?.id) + assertEquals(prefEntry.date.toEpochSecond(), this?.date?.toEpochSecond()) + } + + // start driver which internally invokes the migration to datastore + val sut = sut(testContext, backgroundScope) + + val hostIdBytes = sut.loadData(SecureStorageKey.HostId) + val hostId = hostIdBytes?.let { newHostIdFromJsonBytes(jsonBytes = it) } + + assertEquals(prefEntry.id, hostId?.id) + assertEquals( + prefEntry.date.toEpochSecond(), + hostId?.generatedAt?.toEpochSecond() + ) + } + + @Test + fun testHostIdMigratedDirectlyToDatastore() = runTest { + // Setup device id in preferences + val prefEntry = NewHostIdEntry.random() + val oldSharedPreferences = testContext.getSharedPreferences( + OLD_DEVICE_INFO_PREFERENCES, + Context.MODE_PRIVATE + ) + // store info in old preferences + oldSharedPreferences + .edit() + .putString("key_device_info", Json.encodeToString(prefEntry)) + .commit() + // Assert that info is stored in old preferences at this point + with( + oldSharedPreferences.getString("key_device_info", null)?.let { + Json.decodeFromString(it) + } + ) { + assertEquals(prefEntry.id, this?.id) + assertEquals(prefEntry.date.toEpochSecond(), this?.date?.toEpochSecond()) + } + + + // start driver which internally invokes the migration to datastore + val sut = sut(testContext, backgroundScope) + + val hostIdBytes = sut.loadData(SecureStorageKey.HostId) + val hostId = hostIdBytes?.let { newHostIdFromJsonBytes(jsonBytes = it) } + + assertEquals(prefEntry.id, hostId?.id) + assertEquals( + prefEntry.date.toEpochSecond(), + hostId?.generatedAt?.toEpochSecond() + ) + } + + @Test + fun testEmptyDeviceInfoPrefsMigratedDirectlyToDatastoreReturnsNull() = runTest { + // start driver which internally invokes the migration to datastore + val sut = sut(testContext, backgroundScope) + + val hostIdBytes = sut.loadData(SecureStorageKey.HostId) + val hostId = hostIdBytes?.let { newHostIdFromJsonBytes(jsonBytes = it) } + + assertNull(hostId) + } + + @Test + fun testHostIdCleared() = runTest { + // start driver which internally invokes the migration to datastore + val sut = sut(testContext, backgroundScope) + + val json = hostIdToJsonBytes(HostId.sample()) + sut.saveData(SecureStorageKey.HostId, json) + assertEquals( + json, + sut.loadData(SecureStorageKey.HostId) + ) + + sut.deleteDataForKey(SecureStorageKey.HostId) + + assertNull(sut.loadData(SecureStorageKey.HostId)) + } + + // Newer data type stored into preferences or datastore (if already migrated) + @Serializable + private data class NewHostIdEntry( + @Serializable(with = UuidSerializer::class) + val id: Uuid, + @Serializable(with = TimestampSerializer::class) + val date: Timestamp + ) { + + companion object { + fun random() = HostIdAndroidEntry( + id = UUID.randomUUID(), + date = Timestamp.now() + ) + } + + } + + // The old data type stored into preferences + @Serializable + private data class OldDeviceInfoEntry( + @Serializable(with = UuidSerializer::class) + val id: Uuid, + @Serializable(with = TimestampSerializer::class) + val date: Timestamp, + val name: String, + val manufacturer: String, + val model: String + ) { + + companion object { + fun random() = OldDeviceInfoEntry( + id = UUID.randomUUID(), + date = Timestamp.now(), + name = "Unit", + manufacturer = "-", + model = "Test" + ) + } + + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverTest.kt b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverTest.kt new file mode 100644 index 000000000..fc44800c7 --- /dev/null +++ b/jvm/sargon-android/src/androidTest/kotlin/com/radixdlt/sargon/os/driver/AndroidStorageDriverTest.kt @@ -0,0 +1,147 @@ +package com.radixdlt.sargon.os.driver + +import android.content.Context +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.radixdlt.sargon.Profile +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.UnsafeStorageKey +import com.radixdlt.sargon.Uuid +import com.radixdlt.sargon.extensions.bagOfBytes +import com.radixdlt.sargon.extensions.fromJson +import com.radixdlt.sargon.extensions.randomBagOfBytes +import com.radixdlt.sargon.extensions.string +import com.radixdlt.sargon.extensions.toJson +import com.radixdlt.sargon.samples.sample +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * For tests specific to some keys refer to: + * * [AndroidStorageDriverHostIdTest] for [SecureStorageKey.HostId] + * * [AndroidStorageDriverDeviceFactorSourceMnemonicTest] for [SecureStorageKey.DeviceFactorSourceMnemonic] + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class AndroidStorageDriverTest { + + private val testContext: Context = ApplicationProvider.getApplicationContext() + + @After + fun deleteDatastores() { + File(testContext.filesDir, "datastore").deleteRecursively() + testContext.getSharedPreferences( + OLD_DEVICE_INFO_PREFERENCES, + Context.MODE_PRIVATE + ).edit().clear().commit() + } + + @Test + fun testProfileKeyWhenExists() = runTest { + val sut = sut(testContext, backgroundScope) + val profile = Profile.sample() + val jsonBytes = bagOfBytes(profile.toJson()) + val key = SecureStorageKey.ProfileSnapshot + sut.saveData(key, jsonBytes) + + val receivedBytes = sut.loadData(key) + + assertEquals( + profile, + receivedBytes?.string?.let { Profile.fromJson(it) } + ) + } + + @Test + fun testProfileKeyWhenDeleted() = runTest { + val sut = sut(testContext, backgroundScope) + val profile = Profile.sample() + val jsonBytes = bagOfBytes(profile.toJson()) + val key = SecureStorageKey.ProfileSnapshot + sut.saveData(key, jsonBytes) + assertEquals( + profile, + sut.loadData(key)?.string?.let { Profile.fromJson(it) } + ) + + sut.deleteDataForKey(key) + + assertNull(sut.loadData(key)) + } + + @Test + fun testCrudForByteArrayUnsafeKey() = runTest { + val sut = sut(testContext, backgroundScope) + + val bytes = randomBagOfBytes(2) + sut.saveData(UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN, bytes) + assertEquals( + bytes, + sut.loadData(UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN) + ) + sut.deleteDataForKey(UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN) + assertNull( + sut.loadData(UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN) + ) + } + + companion object { + internal fun sut( + context: Context, + scope: CoroutineScope, + onAuthorize: () -> Result = { Result.success(Unit) } + ) = AndroidStorageDriver( + encryptedPreferencesDatastore = encryptedDataStore(context, scope), + preferencesDatastore = unEncryptedDataStore(context, scope), + deviceInfoDatastore = deviceInfoDataStore(context, scope), + biometricAuthorizationDriver = TestBiometricAuthorizationDriver(onAuthorize) + ) + + private class TestBiometricAuthorizationDriver( + private val onAuthorize: () -> Result + ): BiometricAuthorizationDriver { + override suspend fun authorize(): Result = onAuthorize() + } + + private fun encryptedDataStore( + context: Context, + scope: CoroutineScope, + ) = PreferenceDataStoreFactory.create( + scope = scope + ) { context.testDatastoreFile() } + + private fun unEncryptedDataStore( + context: Context, + scope: CoroutineScope, + ) = PreferenceDataStoreFactory.create( + scope = scope + ) { context.testDatastoreFile() } + + private fun deviceInfoDataStore( + context: Context, + scope: CoroutineScope, + ) = PreferenceDataStoreFactory.create( + scope = scope, + migrations = listOf(SharedPreferencesMigration(context, OLD_DEVICE_INFO_PREFERENCES)), + ) { context.testDatastoreFile() } + + // Files need to be random in order for tests to run in parallel. + // Multiple instances of the same file cannot be open at the same time. + private fun Context.testDatastoreFile() = preferencesDataStoreFile( + Uuid.randomUUID().toString() + ) + + const val OLD_DEVICE_INFO_PREFERENCES = "device_prefs" + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/AndroidManifest.xml b/jvm/sargon-android/src/main/AndroidManifest.xml index a5918e68a..ecebd576e 100644 --- a/jvm/sargon-android/src/main/AndroidManifest.xml +++ b/jvm/sargon-android/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/BagOfBytes.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/BagOfBytes.kt index 50e7e5cf8..ef3bc98ca 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/BagOfBytes.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/BagOfBytes.kt @@ -34,4 +34,6 @@ internal fun randomBagOfBytes(byteCount: Int): BagOfBytes = with(SecureRandom()) val bytes = ByteArray(byteCount) nextBytes(bytes) bytes.toBagOfBytes() -} \ No newline at end of file +} + +internal fun BagOfBytes.toByteArray() = toUByteArray().toByteArray() \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Decimal192.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Decimal192.kt index f95cc7441..eaad4ddb8 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Decimal192.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Decimal192.kt @@ -105,6 +105,7 @@ fun Decimal192.Companion.parseFromTextField( /** * A pair of an input represented with a decimal */ +@KoverIgnore class TextInputDecimal( val input: String, decimalSeparator: Char diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/FactorSource.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/FactorSource.kt index 1524ceded..03906418c 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/FactorSource.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/FactorSource.kt @@ -1,5 +1,6 @@ package com.radixdlt.sargon.extensions +import com.radixdlt.sargon.ArculusCardFactorSource import com.radixdlt.sargon.DeviceFactorSource import com.radixdlt.sargon.FactorSource import com.radixdlt.sargon.FactorSourceId @@ -7,6 +8,9 @@ import com.radixdlt.sargon.FactorSourceKind import com.radixdlt.sargon.HostInfo import com.radixdlt.sargon.LedgerHardwareWalletFactorSource import com.radixdlt.sargon.MnemonicWithPassphrase +import com.radixdlt.sargon.OffDeviceMnemonicFactorSource +import com.radixdlt.sargon.SecurityQuestionsNotProductionReadyFactorSource +import com.radixdlt.sargon.TrustedContactFactorSource import com.radixdlt.sargon.deviceFactorSourceIsMainBdfs import com.radixdlt.sargon.factorSourceSupportsBabylon import com.radixdlt.sargon.factorSourceSupportsOlympia @@ -17,18 +21,29 @@ val FactorSource.id: FactorSourceId get() = when (this) { is FactorSource.Device -> value.id.asGeneral() is FactorSource.Ledger -> value.id.asGeneral() - else -> throw NotImplementedError() + is FactorSource.ArculusCard -> value.id.asGeneral() + is FactorSource.OffDeviceMnemonic -> value.id.asGeneral() + is FactorSource.SecurityQuestions -> value.id.asGeneral() + is FactorSource.TrustedContact -> value.id.asGeneral() } val FactorSource.kind: FactorSourceKind get() = when (this) { is FactorSource.Device -> value.kind is FactorSource.Ledger -> value.kind - else -> throw NotImplementedError() + is FactorSource.ArculusCard -> value.kind + is FactorSource.OffDeviceMnemonic -> value.kind + is FactorSource.SecurityQuestions -> value.kind + is FactorSource.TrustedContact -> value.kind } fun DeviceFactorSource.asGeneral() = FactorSource.Device(value = this) fun LedgerHardwareWalletFactorSource.asGeneral() = FactorSource.Ledger(value = this) +fun ArculusCardFactorSource.asGeneral() = FactorSource.ArculusCard(value = this) +fun OffDeviceMnemonicFactorSource.asGeneral() = FactorSource.OffDeviceMnemonic(value = this) +fun SecurityQuestionsNotProductionReadyFactorSource.asGeneral() = + FactorSource.SecurityQuestions(value = this) +fun TrustedContactFactorSource.asGeneral() = FactorSource.TrustedContact(value = this) fun FactorSource.Device.Companion.olympia( mnemonicWithPassphrase: MnemonicWithPassphrase, @@ -63,3 +78,15 @@ val DeviceFactorSource.kind: FactorSourceKind val LedgerHardwareWalletFactorSource.kind: FactorSourceKind get() = FactorSourceKind.LEDGER_HQ_HARDWARE_WALLET +val ArculusCardFactorSource.kind: FactorSourceKind + get() = FactorSourceKind.ARCULUS_CARD + +val OffDeviceMnemonicFactorSource.kind: FactorSourceKind + get() = FactorSourceKind.OFF_DEVICE_MNEMONIC + +val SecurityQuestionsNotProductionReadyFactorSource.kind: FactorSourceKind + get() = FactorSourceKind.SECURITY_QUESTIONS + +val TrustedContactFactorSource.kind: FactorSourceKind + get() = FactorSourceKind.TRUSTED_CONTACT + diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/HomeCardsManager.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/HomeCardsManager.kt index a1c11e0fa..3ce662b15 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/HomeCardsManager.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/HomeCardsManager.kt @@ -5,17 +5,19 @@ import androidx.datastore.preferences.core.Preferences import com.radixdlt.sargon.HomeCardsManager import com.radixdlt.sargon.HomeCardsObserver import com.radixdlt.sargon.NetworkId -import com.radixdlt.sargon.antenna.SargonNetworkingDriver +import com.radixdlt.sargon.annotation.KoverIgnore +import com.radixdlt.sargon.os.driver.AndroidNetworkingDriver import com.radixdlt.sargon.os.homecards.HomeCardsStorageImpl import okhttp3.OkHttpClient +@KoverIgnore fun HomeCardsManager.Companion.init( okHttpClient: OkHttpClient, networkId: NetworkId, dataStore: DataStore, observer: HomeCardsObserver ) = HomeCardsManager( - networkingDriver = SargonNetworkingDriver(client = okHttpClient), + networkingDriver = AndroidNetworkingDriver(client = okHttpClient), networkId = networkId, cardsStorage = HomeCardsStorageImpl(dataStore = dataStore), observer = observer diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/RadixConnectMobile.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/RadixConnectMobile.kt index dbc47a90d..cbbf1c66a 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/RadixConnectMobile.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/RadixConnectMobile.kt @@ -3,7 +3,7 @@ package com.radixdlt.sargon.extensions import android.content.Context import com.radixdlt.sargon.RadixConnectMobile import com.radixdlt.sargon.annotation.KoverIgnore -import com.radixdlt.sargon.antenna.SargonNetworkingDriver +import com.radixdlt.sargon.os.driver.AndroidNetworkingDriver import com.radixdlt.sargon.os.radixconnect.RadixConnectSessionStorage import okhttp3.OkHttpClient @@ -12,6 +12,6 @@ fun RadixConnectMobile.Companion.init( context: Context, okHttpClient: OkHttpClient ) = RadixConnectMobile( - networkingDriver = SargonNetworkingDriver(client = okHttpClient), + networkingDriver = AndroidNetworkingDriver(client = okHttpClient), sessionStorage = RadixConnectSessionStorage(context = context) ) \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Result.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Result.kt index 238500cba..773ab7404 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Result.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/Result.kt @@ -1,5 +1,8 @@ package com.radixdlt.sargon.extensions +import com.radixdlt.sargon.annotation.KoverIgnore +import timber.log.Timber + inline fun Result.then( other: (FirstResult) -> Result ): Result = fold( @@ -21,3 +24,10 @@ inline fun Result.mapError( onSuccess = { Result.success(it) }, onFailure = { Result.failure(map(it)) } ) + +fun Result.toUnit() = map {} + +@KoverIgnore +internal fun Result.logFailure(): Result = onFailure { error -> + Timber.w(error) +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/UnsafeStorageKey.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/UnsafeStorageKey.kt new file mode 100644 index 000000000..485bfc1f5 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/extensions/UnsafeStorageKey.kt @@ -0,0 +1,7 @@ +package com.radixdlt.sargon.extensions + +import com.radixdlt.sargon.UnsafeStorageKey +import com.radixdlt.sargon.unsafeStorageKeyIdentifier + +val UnsafeStorageKey.identifier: String + get() = unsafeStorageKeyIdentifier(key = this) \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt new file mode 100644 index 000000000..824b240f8 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/Bios.kt @@ -0,0 +1,57 @@ +package com.radixdlt.sargon.os + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.radixdlt.sargon.Bios +import com.radixdlt.sargon.Drivers +import com.radixdlt.sargon.annotation.KoverIgnore +import com.radixdlt.sargon.os.driver.AndroidBiometricAuthorizationDriver +import com.radixdlt.sargon.os.driver.AndroidEntropyProviderDriver +import com.radixdlt.sargon.os.driver.AndroidEventBusDriver +import com.radixdlt.sargon.os.driver.AndroidFileSystemDriver +import com.radixdlt.sargon.os.driver.AndroidHostInfoDriver +import com.radixdlt.sargon.os.driver.AndroidLoggingDriver +import com.radixdlt.sargon.os.driver.AndroidNetworkingDriver +import com.radixdlt.sargon.os.driver.AndroidProfileChangeDriver +import com.radixdlt.sargon.os.driver.AndroidStorageDriver +import com.radixdlt.sargon.os.driver.BiometricsHandler +import okhttp3.OkHttpClient +import timber.log.Timber + +@KoverIgnore +fun Bios.Companion.from( + context: Context, + enableLogging: Boolean, + httpClient: OkHttpClient, + biometricsHandler: BiometricsHandler, + encryptedPreferencesDataStore: DataStore, + preferencesDatastore: DataStore, + deviceInfoDatastore: DataStore +): Bios { + if (enableLogging) { + Timber.plant(Timber.DebugTree()) + } + + val storageDriver = AndroidStorageDriver( + biometricAuthorizationDriver = AndroidBiometricAuthorizationDriver( + biometricsHandler = biometricsHandler + ), + encryptedPreferencesDatastore = encryptedPreferencesDataStore, + preferencesDatastore = preferencesDatastore, + deviceInfoDatastore = deviceInfoDatastore, + ) + return Bios( + drivers = Drivers( + networking = AndroidNetworkingDriver(client = httpClient), + secureStorage = storageDriver, + unsafeStorage = storageDriver, + entropyProvider = AndroidEntropyProviderDriver(), + hostInfo = AndroidHostInfoDriver(context), + logging = AndroidLoggingDriver(), + eventBus = AndroidEventBusDriver(), + fileSystem = AndroidFileSystemDriver(context), + profileChangeDriver = AndroidProfileChangeDriver() + ) + ) +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt new file mode 100644 index 000000000..83df56e66 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidBiometricAuthorizationDriver.kt @@ -0,0 +1,174 @@ +package com.radixdlt.sargon.os.driver + +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.radixdlt.sargon.annotation.KoverIgnore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal interface BiometricAuthorizationDriver { + + suspend fun authorize(): Result + +} + +sealed class BiometricsFailure(override val message: String?) : Exception() { + + data class AuthenticationNotPossible( + val authenticationStatus: Int + ) : BiometricsFailure( + message = "Biometrics failed to request. canAuthenticate() returned [$authenticationStatus] ${authenticationStatus.toAuthenticationStatusMessage()}" + ) + + data class AuthenticationError( + val errorCode: Int, + val errorMessage: String + ) : BiometricsFailure( + message = "User did not authorize. Received [$errorCode]: $errorMessage" + ) + + companion object { + private fun Int.toAuthenticationStatusMessage(): String = when (this) { + BiometricManager.BIOMETRIC_SUCCESS -> + "The user can successfully authenticate." + + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> + "Unable to determine whether the user can authenticate." + + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> + "The user can't authenticate because the specified options are incompatible with the current Android version." + + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> + "The user can't authenticate because the hardware is unavailable. Try again later." + + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + "The user can't authenticate because no biometric or device credential is enrolled." + + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> + "The user can't authenticate because there is no suitable hardware (e. g. no biometric sensor or no keyguard)." + + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> + "The user can't authenticate because a security vulnerability has been discovered with one or more hardware sensors. The affected sensor(s) are unavailable until a security update has addressed the issue." + + else -> "" + } + } + +} + +internal class AndroidBiometricAuthorizationDriver( + private val biometricsHandler: BiometricsHandler +) : BiometricAuthorizationDriver { + + + override suspend fun authorize(): Result = biometricsHandler.askForBiometrics() + +} + +class BiometricsHandler( + internal val biometricsSystemDialogTitle: String +) { + + private val biometricRequestsChannel = Channel() + private val biometricsResultsChannel = Channel>() + + fun register(activity: FragmentActivity) { + activity.lifecycleScope.launch { + // Listen to biometric prompt requests while the activity is at least started. + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + biometricRequestsChannel.receiveAsFlow().collectLatest { + val result = requestBiometricsAuthorization(activity) + + // Send back the result to sargon os + biometricsResultsChannel.send(result) + } + } + } + } + + internal suspend fun askForBiometrics(): Result { + // Suspend until an activity is subscribed to this channel + withTimeout(5000) { + biometricRequestsChannel.send(Unit) + } + + // If an activity is already registered, then we need to wait until the user provides + // the response from the biometrics prompt + return biometricsResultsChannel.receive() + } + + private suspend fun requestBiometricsAuthorization( + activity: FragmentActivity + ): Result = withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + val biometricManager = BiometricManager.from(activity) + + val authenticationPreCheckStatus = + biometricManager.canAuthenticate(allowedAuthenticators) + if (authenticationPreCheckStatus != BiometricManager.BIOMETRIC_SUCCESS) { + continuation.resume( + Result.failure( + BiometricsFailure.AuthenticationNotPossible(authenticationPreCheckStatus) + ) + ) + return@suspendCoroutine + } + + val authCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + continuation.resume(Result.success(Unit)) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + continuation.resume( + Result.failure( + BiometricsFailure.AuthenticationError( + errorCode, + errString.toString() + ) + ) + ) + } + + override fun onAuthenticationFailed() { + Timber.tag("Sargon").w("Biometrics failed.") + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(biometricsSystemDialogTitle) + .setAllowedAuthenticators(allowedAuthenticators) + .build() + + val biometricPrompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + authCallback + ) + + biometricPrompt.authenticate(promptInfo) + } + } + + private val allowedAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } else { + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEntropyProviderDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEntropyProviderDriver.kt new file mode 100644 index 000000000..a20dcd71c --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEntropyProviderDriver.kt @@ -0,0 +1,9 @@ +package com.radixdlt.sargon.os.driver + +import com.radixdlt.sargon.Entropy32Bytes +import com.radixdlt.sargon.EntropyProviderDriver +import com.radixdlt.sargon.extensions.random + +class AndroidEntropyProviderDriver: EntropyProviderDriver { + override fun generateSecureRandomBytes(): Entropy32Bytes = Entropy32Bytes.random() +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt new file mode 100644 index 000000000..60ae775a0 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriver.kt @@ -0,0 +1,17 @@ +package com.radixdlt.sargon.os.driver + +import com.radixdlt.sargon.EventBusDriver +import com.radixdlt.sargon.EventNotification +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class AndroidEventBusDriver: EventBusDriver { + + private val _events = MutableSharedFlow() + val events: Flow = _events.asSharedFlow() + + override suspend fun handleEventNotification(eventNotification: EventNotification) { + _events.emit(eventNotification) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidFileSystemDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidFileSystemDriver.kt new file mode 100644 index 000000000..83511bced --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidFileSystemDriver.kt @@ -0,0 +1,70 @@ +package com.radixdlt.sargon.os.driver + +import android.content.Context +import android.net.Uri +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.FileSystemDriver +import com.radixdlt.sargon.extensions.logFailure +import com.radixdlt.sargon.extensions.toBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source +import java.io.File + +class AndroidFileSystemDriver( + private val context: Context, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +): FileSystemDriver { + + override suspend fun loadFromFile(path: String): BagOfBytes? = withContext(dispatcher) { + runCatching { + context.contentResolver.openInputStream(path.toUri())?.use { stream -> + stream.source().buffer().readByteArray() + } + }.logFailure().getOrNull()?.toBagOfBytes() + } + + override suspend fun saveToFile(path: String, data: BagOfBytes) { + withContext(dispatcher) { + runCatching { + val fileToSave = path.toFile() + + if (!fileToSave.exists()) { + if (fileToSave.parentFile?.exists() == false) { + fileToSave.parentFile?.mkdirs() + } + fileToSave.createNewFile() + } + + context.contentResolver.openOutputStream( + path.toUri(), + "wt" // Stream opened with write and truncate mode + )?.use { stream -> + stream.write(data.toByteArray()) + } + }.logFailure() + } + } + + override suspend fun deleteFile(path: String) { + withContext(Dispatchers.IO) { + runCatching { + path.toFile().delete() + }.logFailure() + } + } + + private val directory: File + get() = File(context.filesDir, BASE_DIR) + + private fun String.toFile() = File(directory, this) + private fun File.toUri() = Uri.fromFile(this) + private fun String.toUri(): Uri = toFile().toUri() + + internal companion object { + const val BASE_DIR = "sargon" + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidHostInfoDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidHostInfoDriver.kt new file mode 100644 index 000000000..17e2db826 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidHostInfoDriver.kt @@ -0,0 +1,49 @@ +package com.radixdlt.sargon.os.driver + +import android.content.Context +import android.os.Build +import android.provider.Settings +import com.radixdlt.sargon.HostInfoDriver +import com.radixdlt.sargon.HostOs +import com.radixdlt.sargon.extensions.android +import java.util.Locale + +class AndroidHostInfoDriver( + private val context: Context +) : HostInfoDriver { + + override suspend fun hostOs(): HostOs = HostOs.android( + vendor = getVendor(), + version = getAndroidVersion() + ) + + override suspend fun hostDeviceName(): String = getDeviceName(context) + + override suspend fun hostAppVersion(): String = runCatching { + context.applicationContext.packageManager.getPackageInfo( + context.applicationContext.packageName, + 0 + ).versionName.takeUnless { + // versionName may return "null" as a string + it == "null" + } + }.getOrNull().orEmpty() + + override suspend fun hostDeviceModel(): String = getDeviceModel() + + private fun getDeviceName(context: Context) = Settings.Global.getString( + context.applicationContext.contentResolver, + Settings.Global.DEVICE_NAME + ).orEmpty() + + private fun getVendor() = Build.MANUFACTURER.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + + private fun getDeviceModel() = Build.MODEL.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + + private fun getAndroidVersion(): String = + "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt new file mode 100644 index 000000000..2b3a86163 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriver.kt @@ -0,0 +1,17 @@ +package com.radixdlt.sargon.os.driver + +import com.radixdlt.sargon.LogLevel +import com.radixdlt.sargon.LoggingDriver +import timber.log.Timber + +class AndroidLoggingDriver: LoggingDriver { + override fun log(level: LogLevel, msg: String) { + when (level) { + LogLevel.ERROR -> Timber.e(msg) + LogLevel.WARN -> Timber.w(msg) + LogLevel.INFO -> Timber.i(msg) + LogLevel.DEBUG -> Timber.d(msg) + LogLevel.TRACE -> Timber.v(msg) + } + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/antenna/SargonNetworkingDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidNetworkingDriver.kt similarity index 84% rename from jvm/sargon-android/src/main/java/com/radixdlt/sargon/antenna/SargonNetworkingDriver.kt rename to jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidNetworkingDriver.kt index 941cd163b..77ee28107 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/antenna/SargonNetworkingDriver.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidNetworkingDriver.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalUnsignedTypes::class) -package com.radixdlt.sargon.antenna +package com.radixdlt.sargon.os.driver import com.radixdlt.sargon.CommonException import com.radixdlt.sargon.NetworkRequest @@ -9,18 +9,22 @@ import com.radixdlt.sargon.NetworkingDriver import com.radixdlt.sargon.annotation.KoverIgnore import com.radixdlt.sargon.extensions.toBagOfBytes import com.radixdlt.sargon.extensions.toHttpMethod +import kotlinx.coroutines.ExperimentalCoroutinesApi import okhttp3.Headers.Companion.toHeaders import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import okhttp3.executeAsync +import okhttp3.coroutines.executeAsync -class SargonNetworkingDriver( +class AndroidNetworkingDriver( private val client: OkHttpClient ) : NetworkingDriver { - override suspend fun executeNetworkRequest(request: NetworkRequest): NetworkResponse = runCatching { + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun executeNetworkRequest( + request: NetworkRequest + ): NetworkResponse = runCatching { val mediaType = request.headers.extractMediaType() val requestBody = request.body diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileChangeDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileChangeDriver.kt new file mode 100644 index 000000000..2e92bee75 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidProfileChangeDriver.kt @@ -0,0 +1,17 @@ +package com.radixdlt.sargon.os.driver + +import com.radixdlt.sargon.Profile +import com.radixdlt.sargon.ProfileChangeDriver +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class AndroidProfileChangeDriver : ProfileChangeDriver { + + private val _profile = MutableSharedFlow() + val profile: Flow = _profile.asSharedFlow() + + override suspend fun handleProfileChange(changedProfile: Profile) { + _profile.emit(changedProfile) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt new file mode 100644 index 000000000..e35fdb05e --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/driver/AndroidStorageDriver.kt @@ -0,0 +1,116 @@ +package com.radixdlt.sargon.os.driver + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.CommonException +import com.radixdlt.sargon.SecureStorageDriver +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.UnsafeStorageDriver +import com.radixdlt.sargon.UnsafeStorageKey +import com.radixdlt.sargon.extensions.then +import com.radixdlt.sargon.os.storage.key.ByteArrayKeyMapping +import com.radixdlt.sargon.os.storage.key.DeviceFactorSourceMnemonicKeyMapping +import com.radixdlt.sargon.os.storage.key.HostIdKeyMapping +import com.radixdlt.sargon.os.storage.key.ProfileSnapshotKeyMapping +import timber.log.Timber + +internal class AndroidStorageDriver( + private val biometricAuthorizationDriver: BiometricAuthorizationDriver, + private val encryptedPreferencesDatastore: DataStore, + private val preferencesDatastore: DataStore, + private val deviceInfoDatastore: DataStore +) : SecureStorageDriver, UnsafeStorageDriver { + + override suspend fun loadData(key: SecureStorageKey): BagOfBytes? = key + .mapping() + .then { it.read() } + .reportFailure( + "Failed to load data for $key", + CommonException.SecureStorageReadException() + ) + .getOrNull() + + override suspend fun saveData(key: SecureStorageKey, data: BagOfBytes) { + key.mapping() + .then { it.write(data) } + .reportFailure( + "Failed to save data for $key", + CommonException.SecureStorageWriteException() + ) + } + + override suspend fun deleteDataForKey(key: SecureStorageKey) { + key.mapping() + .then { it.remove() } + .reportFailure( + "Failed to remove data for $key", + CommonException.SecureStorageWriteException() + ) + } + + override suspend fun loadData(key: UnsafeStorageKey): BagOfBytes? = key + .mapping() + .then { it.read() } + .reportFailure( + "Failed to load data for $key", + CommonException.UnsafeStorageReadException() + ) + .getOrNull() + + override suspend fun saveData(key: UnsafeStorageKey, data: BagOfBytes) { + key.mapping() + .then { it.write(data) } + .reportFailure( + "Failed to save data for $key", + CommonException.UnsafeStorageWriteException() + ) + } + + override suspend fun deleteDataForKey(key: UnsafeStorageKey) { + key.mapping() + .then { it.remove() } + .reportFailure( + "Failed to remove data for $key", + CommonException.UnsafeStorageWriteException() + ) + } + + private fun SecureStorageKey.mapping() = when (this) { + is SecureStorageKey.ProfileSnapshot -> ProfileSnapshotKeyMapping( + key = this, + encryptedStorage = encryptedPreferencesDatastore + ) + + is SecureStorageKey.HostId -> HostIdKeyMapping( + key = this, + deviceStorage = deviceInfoDatastore + ) + + is SecureStorageKey.DeviceFactorSourceMnemonic -> DeviceFactorSourceMnemonicKeyMapping( + key = this, + encryptedStorage = encryptedPreferencesDatastore, + biometricAuthorizationDriver = biometricAuthorizationDriver + ) + }.let { mapping -> + Result.success(mapping) + } + + private fun UnsafeStorageKey.mapping() = when (this) { + UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN -> ByteArrayKeyMapping( + key = this, + storage = preferencesDatastore + ) + }.let { mapping -> + Result.success(mapping) + } + + private fun Result.reportFailure(message: String, commonError: CommonException) = + onFailure { error -> + Timber.tag("Sargon").w(error, message) + when (error) { + is CommonException -> throw error + else -> throw commonError + } + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/homecards/HomeCardsStorage.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/homecards/HomeCardsStorage.kt index 7fe39f377..af2cb993a 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/homecards/HomeCardsStorage.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/homecards/HomeCardsStorage.kt @@ -7,25 +7,21 @@ import com.radixdlt.sargon.BagOfBytes import com.radixdlt.sargon.HomeCardsStorage import com.radixdlt.sargon.annotation.KoverIgnore import com.radixdlt.sargon.extensions.toBagOfBytes -import com.radixdlt.sargon.os.storage.PreferencesStorage +import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.os.storage.write +@KoverIgnore internal class HomeCardsStorageImpl internal constructor( - private val storage: PreferencesStorage + private val dataStore: DataStore ) : HomeCardsStorage { - @KoverIgnore - constructor(dataStore: DataStore) : this( - storage = PreferencesStorage( - datastore = dataStore - ) - ) - override suspend fun saveCards(encodedCards: BagOfBytes) { - storage.set(KEY_HOME_CARDS, encodedCards.toUByteArray().toByteArray()) + dataStore.write(KEY_HOME_CARDS, encodedCards.toByteArray()) } override suspend fun loadCards(): BagOfBytes? { - return storage.get(KEY_HOME_CARDS).getOrNull()?.toBagOfBytes() + return dataStore.read(KEY_HOME_CARDS).getOrNull()?.toBagOfBytes() } companion object { diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorage.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorage.kt index 0067acdcd..fd3260b2d 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorage.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorage.kt @@ -1,42 +1,44 @@ -@file:OptIn(ExperimentalUnsignedTypes::class) - package com.radixdlt.sargon.os.radixconnect import android.content.Context +import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.byteArrayPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import com.radixdlt.sargon.BagOfBytes -import com.radixdlt.sargon.SessionId import com.radixdlt.sargon.RadixConnectMobileSessionStorage +import com.radixdlt.sargon.SessionId import com.radixdlt.sargon.annotation.KoverIgnore import com.radixdlt.sargon.extensions.toBagOfBytes -import com.radixdlt.sargon.os.storage.EncryptedPreferencesStorage -import com.radixdlt.sargon.os.storage.KeySpec +import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.os.storage.write internal class RadixConnectSessionStorage internal constructor( - private val storage: EncryptedPreferencesStorage + private val dataStore: DataStore ) : RadixConnectMobileSessionStorage { @KoverIgnore constructor(context: Context) : this( - storage = EncryptedPreferencesStorage(datastore = PreferenceDataStoreFactory.create() { + dataStore = PreferenceDataStoreFactory.create { val applicationContext = context.applicationContext applicationContext.preferencesDataStoreFile(STORAGE_FILE_NAME) - }) + } ) override suspend fun saveSession(sessionId: SessionId, encodedSession: BagOfBytes) { - storage.set( - sessionId.key(), - encodedSession.toUByteArray().toByteArray(), - KeySpec.RadixConnect() + dataStore.write( + key = sessionId.key(), + value = encodedSession.toByteArray(), + keystoreAccessRequest = KeystoreAccessRequest.ForRadixConnect ) } - override suspend fun loadSession(sessionId: SessionId): BagOfBytes? = storage.get( + override suspend fun loadSession(sessionId: SessionId): BagOfBytes? = dataStore.read( key = sessionId.key(), - keySpec = KeySpec.RadixConnect() + keystoreAccessRequest = KeystoreAccessRequest.ForRadixConnect ).getOrNull()?.toBagOfBytes() private fun SessionId.key() = byteArrayPreferencesKey(name = toString()) diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptedPreferencesStorage.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptedPreferencesStorage.kt deleted file mode 100644 index 4e018848a..000000000 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/EncryptedPreferencesStorage.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.radixdlt.sargon.os.storage - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import com.radixdlt.sargon.extensions.then -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -internal class EncryptedPreferencesStorage( - private val datastore: DataStore -) { - - suspend fun get(key: Preferences.Key, keySpec: KeySpec): Result = runCatching { - datastore.data.catchIOException().map { preferences -> preferences[key] }.first() - }.then { encrypted -> - if (encrypted == null) return@then Result.success(null) - - encrypted.decrypt(keySpec = keySpec) - } - - suspend fun set( - key: Preferences.Key, - value: T, - keySpec: KeySpec - ): Result = value.encrypt(keySpec = keySpec).mapCatching { encrypted -> - datastore.edit { preferences -> - preferences[key] = encrypted - } - } - - suspend fun remove(key: Preferences.Key) { - datastore.edit { preferences -> - preferences.remove(key) - } - } -} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt index 81d71ac2f..1e3206239 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/KeySpec.kt @@ -10,8 +10,42 @@ import java.security.ProviderException import javax.crypto.KeyGenerator import javax.crypto.SecretKey -// Unfortunately this class relies on Android APIs which are difficult to mock in unit tests. -// Integration tests can cover it, but they need to run on an actual device. +/** + * A request to the keystore that describes which [KeySpec] should be used for cryptographic + * operations. [requestAuthorization] is only invoked for [KeySpec]s that are defined with + * [KeyGenParameterSpec#Builder#setUserAuthenticationRequired] to true + */ +internal sealed interface KeystoreAccessRequest { + + val keySpec: KeySpec + + suspend fun requestAuthorization(): Result + + data object ForProfile: KeystoreAccessRequest { + override val keySpec: KeySpec = KeySpec.Profile() + + override suspend fun requestAuthorization(): Result = Result.success(Unit) + } + + data object ForRadixConnect: KeystoreAccessRequest { + override val keySpec: KeySpec = KeySpec.RadixConnect() + + override suspend fun requestAuthorization(): Result = Result.success(Unit) + } + + data class ForMnemonic( + private val onRequestAuthorization: suspend () -> Result + ): KeystoreAccessRequest { + override val keySpec: KeySpec = KeySpec.Mnemonic() + + override suspend fun requestAuthorization(): Result = onRequestAuthorization() + + } +} + +/** + * The description of the key that describes for cryptographic operations on keystore. + */ @KoverIgnore internal sealed class KeySpec(val alias: String) { diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/PreferencesStorage.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/PreferencesStorage.kt deleted file mode 100644 index e151a42dd..000000000 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/PreferencesStorage.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.radixdlt.sargon.os.storage - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - -internal class PreferencesStorage( - private val datastore: DataStore -) { - - suspend fun get(key: Preferences.Key): Result = runCatching { - datastore.data.catchIOException().map { preferences -> preferences[key] }.first() - } - - suspend fun set( - key: Preferences.Key, - value: T - ) = datastore.edit { preferences -> - preferences[key] = value - } - - suspend fun remove(key: Preferences.Key) { - datastore.edit { preferences -> - preferences.remove(key) - } - } -} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt index b62f8cfb2..bc41afd62 100644 --- a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/StorageUtils.kt @@ -1,11 +1,65 @@ package com.radixdlt.sargon.os.storage +import androidx.datastore.core.DataStore import androidx.datastore.core.IOException import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import com.radixdlt.sargon.annotation.KoverIgnore +import com.radixdlt.sargon.extensions.then +import com.radixdlt.sargon.extensions.toUnit import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +private suspend fun KeystoreAccessRequest?.requestAuthorizationIfNeeded() = + this?.requestAuthorization() ?: Result.success(Unit) + +/** + * Reads the contents associated with the given [key] from the data store. + * If a [KeystoreAccessRequest] is provided then the data written will be decrypted using keystore + */ +internal suspend fun DataStore.read( + key: Preferences.Key, + keystoreAccessRequest: KeystoreAccessRequest? = null +): Result = keystoreAccessRequest + .requestAuthorizationIfNeeded() + .mapCatching { + data.catchIOException().map { preferences -> preferences[key] }.firstOrNull() + }.then { value -> + if (keystoreAccessRequest != null && value != null) { + value.decrypt(keystoreAccessRequest.keySpec) + } else { + Result.success(value) + } + } + +/** + * Associates the [value] with the given [key] to the data store. + * If a [KeystoreAccessRequest] is provided then the data will be encrypted using keystore + */ +internal suspend fun DataStore.write( + key: Preferences.Key, + value: T, + keystoreAccessRequest: KeystoreAccessRequest? = null +): Result = keystoreAccessRequest.requestAuthorizationIfNeeded().then { + if (keystoreAccessRequest != null && value != null) { + value.encrypt(keystoreAccessRequest.keySpec) + } else { + Result.success(value) + } +}.mapCatching { modified -> + edit { preferences -> + preferences[key] = modified + } +}.toUnit() + +internal suspend fun DataStore.remove(key: Preferences.Key) = runCatching { + edit { preferences -> + preferences.remove(key) + } +}.toUnit() @KoverIgnore internal fun Flow.catchIOException() = catch { exception -> diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMapping.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMapping.kt new file mode 100644 index 000000000..f77a443eb --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMapping.kt @@ -0,0 +1,93 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.byteArrayPreferencesKey +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.UnsafeStorageKey +import com.radixdlt.sargon.extensions.identifier +import com.radixdlt.sargon.extensions.toBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.os.storage.remove +import com.radixdlt.sargon.os.storage.write + +internal class ByteArrayKeyMapping private constructor( + private val input: ByteArrayKeyMappingInput +) : DatastoreKeyMapping { + + internal constructor( + key: UnsafeStorageKey, + storage: DataStore + ) : this( + ByteArrayKeyMappingInput.Unsecure( + key = key, + storage = storage + ) + ) + + internal constructor( + key: SecureStorageKey, + keystoreAccessRequest: KeystoreAccessRequest, + storage: DataStore + ) : this( + ByteArrayKeyMappingInput.Secure( + key = key, + keystoreAccessRequest = keystoreAccessRequest, + storage = storage + ) + ) + + private val preferencesKey = when (input) { + is ByteArrayKeyMappingInput.Secure -> byteArrayPreferencesKey(input.key.identifier) + is ByteArrayKeyMappingInput.Unsecure -> byteArrayPreferencesKey(input.key.identifier) + } + + override suspend fun write(bagOfBytes: BagOfBytes): Result = when (input) { + is ByteArrayKeyMappingInput.Secure -> input.storage.write( + preferencesKey, + bagOfBytes.toByteArray(), + input.keystoreAccessRequest + ) + + is ByteArrayKeyMappingInput.Unsecure -> input.storage.write( + key = preferencesKey, + value = bagOfBytes.toByteArray() + ) + } + + override suspend fun read(): Result = when (input) { + is ByteArrayKeyMappingInput.Secure -> input.storage.read( + preferencesKey, + input.keystoreAccessRequest + ).map { + it?.toBagOfBytes() + } + is ByteArrayKeyMappingInput.Unsecure -> input.storage.read(preferencesKey).map { + it?.toBagOfBytes() + } + } + + override suspend fun remove(): Result = when (input) { + is ByteArrayKeyMappingInput.Secure -> input.storage.remove(preferencesKey) + is ByteArrayKeyMappingInput.Unsecure -> input.storage.remove(preferencesKey) + } + + private sealed interface ByteArrayKeyMappingInput { + val storage: DataStore + + data class Unsecure( + val key: UnsafeStorageKey, + override val storage: DataStore, + ) : ByteArrayKeyMappingInput + + data class Secure( + val key: SecureStorageKey, + val keystoreAccessRequest: KeystoreAccessRequest, + override val storage: DataStore, + ) : ByteArrayKeyMappingInput + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/DatastoreKeyMapping.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/DatastoreKeyMapping.kt new file mode 100644 index 000000000..646de03bb --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/DatastoreKeyMapping.kt @@ -0,0 +1,13 @@ +package com.radixdlt.sargon.os.storage.key + +import com.radixdlt.sargon.BagOfBytes + +internal interface DatastoreKeyMapping { + + suspend fun write(bagOfBytes: BagOfBytes): Result + + suspend fun read(): Result + + suspend fun remove(): Result + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/DeviceFactorSourceMnemonicKeyMapping.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/DeviceFactorSourceMnemonicKeyMapping.kt new file mode 100644 index 000000000..4a16e5c82 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/DeviceFactorSourceMnemonicKeyMapping.kt @@ -0,0 +1,58 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.MnemonicWithPassphrase +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.extensions.fromJson +import com.radixdlt.sargon.extensions.hex +import com.radixdlt.sargon.extensions.then +import com.radixdlt.sargon.extensions.toJson +import com.radixdlt.sargon.mnemonicWithPassphraseToJsonBytes +import com.radixdlt.sargon.newMnemonicWithPassphraseFromJsonBytes +import com.radixdlt.sargon.os.driver.BiometricAuthorizationDriver +import com.radixdlt.sargon.os.storage.KeySpec +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.os.storage.remove +import com.radixdlt.sargon.os.storage.write +import timber.log.Timber + +internal class DeviceFactorSourceMnemonicKeyMapping( + private val key: SecureStorageKey.DeviceFactorSourceMnemonic, + private val encryptedStorage: DataStore, + private val biometricAuthorizationDriver: BiometricAuthorizationDriver +): DatastoreKeyMapping { + + private val preferencesKey = stringPreferencesKey("mnemonic${key.factorSourceId.body.hex}") + + override suspend fun write(bagOfBytes: BagOfBytes): Result = runCatching { + newMnemonicWithPassphraseFromJsonBytes(bagOfBytes).toJson() + }.then { json -> + encryptedStorage.write( + key = preferencesKey, + value = json, + keystoreAccessRequest = KeystoreAccessRequest.ForMnemonic( + onRequestAuthorization = { biometricAuthorizationDriver.authorize() } + ) + ) + } + + override suspend fun read(): Result = encryptedStorage.read( + key = preferencesKey, + keystoreAccessRequest = KeystoreAccessRequest.ForMnemonic( + onRequestAuthorization = { biometricAuthorizationDriver.authorize() } + ) + ).mapCatching { androidCompatibleJson -> + if (androidCompatibleJson != null) { + val mnemonicWithPassphrase = MnemonicWithPassphrase.fromJson(androidCompatibleJson) + mnemonicWithPassphraseToJsonBytes(mnemonicWithPassphrase) + } else { + null + } + } + + override suspend fun remove(): Result = encryptedStorage.remove(key = preferencesKey) +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/HostIdKeyMapping.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/HostIdKeyMapping.kt new file mode 100644 index 000000000..b99991448 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/HostIdKeyMapping.kt @@ -0,0 +1,104 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.HostId +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.Timestamp +import com.radixdlt.sargon.Uuid +import com.radixdlt.sargon.extensions.then +import com.radixdlt.sargon.hostIdToJsonBytes +import com.radixdlt.sargon.newHostIdFromJsonBytes +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.os.storage.remove +import com.radixdlt.sargon.os.storage.write +import com.radixdlt.sargon.serializer.TimestampSerializer +import com.radixdlt.sargon.serializer.UuidSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal class HostIdKeyMapping( + private val key: SecureStorageKey, + private val deviceStorage: DataStore +): DatastoreKeyMapping { + + private val preferencesKey = stringPreferencesKey(PREFERENCES_KEY) + + private fun HostId.Companion.fromJsonBytes(jsonBytes: BagOfBytes) = + newHostIdFromJsonBytes(jsonBytes) + private fun HostId.toJsonBytes() = hostIdToJsonBytes(hostId = this) + private fun HostId.asEntry() = HostIdAndroidEntry( + id = id, + date = generatedAt + ) + + override suspend fun write(bagOfBytes: BagOfBytes): Result = runCatching { + HostId.fromJsonBytes(bagOfBytes).asEntry().toJsonString() + }.then { json -> + deviceStorage.write(preferencesKey, json) + } + + override suspend fun read(): Result = deviceStorage.read(preferencesKey) + .mapCatching { entrySerialized -> + if (entrySerialized != null) { + HostIdAndroidEntry.fromJsonString(entrySerialized).toHostId().toJsonBytes() + } else { + null + } + } + + override suspend fun remove(): Result = deviceStorage.remove(preferencesKey) + + companion object { + private const val PREFERENCES_KEY = "key_device_info" + } +} + +/** + * Entry stored on device preferences. Kept to ensure compatibility with previous versions + * + * - In version 1.6.0 DeviceInfo object was introduced in preferences with + * -- id: Uuid, + * -- date: Timestamp, + * -- name: String, + * -- manufacturer: String, + * -- model: String + * the intention was to keep a stable identifier along with some more data. + * + * - From version 1.8.3 there is no need to keep the all the rest of the data in the preferences. + * The update was to bridge compatibility with sargon os and android implementation + * + * Only the id and date are kept and the rest of the values are not needed, since [HostInfo] will + * be calculated on the fly by sargon os. + * So [HostIdAndroidEntry] contains actually a subset of critical data being kept in DeviceInfo previously. + */ +@Serializable +data class HostIdAndroidEntry( + @Serializable(with = UuidSerializer::class) + val id: Uuid, + @Serializable(with = TimestampSerializer::class) + val date: Timestamp +) { + + fun toHostId() = HostId( + id = id, + generatedAt = date + ) + + fun toJsonString(): String = Json.encodeToString(this) + + companion object { + private val jsonSerializer: Json + get() = Json { + // Ignore previous values for compatibility + ignoreUnknownKeys = true + isLenient = true + } + + fun fromJsonString(jsonString: String) = jsonSerializer + .decodeFromString(jsonString) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt new file mode 100644 index 000000000..5d10a2300 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMapping.kt @@ -0,0 +1,47 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.BagOfBytes +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.extensions.bagOfBytes +import com.radixdlt.sargon.extensions.string +import com.radixdlt.sargon.extensions.then +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.os.storage.remove +import com.radixdlt.sargon.os.storage.write + +internal class ProfileSnapshotKeyMapping( + private val key: SecureStorageKey.ProfileSnapshot, + private val encryptedStorage: DataStore +) : DatastoreKeyMapping { + + private val preferenceKey = stringPreferencesKey(KEY) + + override suspend fun write( + bagOfBytes: BagOfBytes + ): Result = runCatching { + bagOfBytes.string + }.then { snapshotString -> + encryptedStorage.write( + key = preferenceKey, + value = snapshotString, + keystoreAccessRequest = KeystoreAccessRequest.ForProfile + ) + } + + override suspend fun read(): Result = encryptedStorage.read( + key = preferenceKey, + keystoreAccessRequest = KeystoreAccessRequest.ForProfile + ).mapCatching { snapshotString -> + snapshotString?.let { bagOfBytes(snapshotString) } + } + + override suspend fun remove(): Result = encryptedStorage.remove(preferenceKey) + + companion object { + private const val KEY = "profile_preferences_key" + } +} diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/serializer/TimestampSerializer.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/serializer/TimestampSerializer.kt new file mode 100644 index 000000000..85fe6f882 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/serializer/TimestampSerializer.kt @@ -0,0 +1,28 @@ +package com.radixdlt.sargon.serializer + +import com.radixdlt.sargon.Timestamp +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.format.DateTimeFormatter + +object TimestampSerializer : KSerializer { + + private const val SERIAL_NAME = "com.radixdlt.sargon.Timestamp" + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = SERIAL_NAME, + kind = PrimitiveKind.STRING + ) + + override fun serialize(encoder: Encoder, value: Timestamp) { + encoder.encodeString(value.format(DateTimeFormatter.ISO_DATE_TIME)) + } + + override fun deserialize(decoder: Decoder): Timestamp { + return Timestamp.parse(decoder.decodeString(), DateTimeFormatter.ISO_DATE_TIME) + } +} diff --git a/jvm/sargon-android/src/main/java/com/radixdlt/sargon/serializer/UuidSerializer.kt b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/serializer/UuidSerializer.kt new file mode 100644 index 000000000..116b89014 --- /dev/null +++ b/jvm/sargon-android/src/main/java/com/radixdlt/sargon/serializer/UuidSerializer.kt @@ -0,0 +1,27 @@ +package com.radixdlt.sargon.serializer + +import com.radixdlt.sargon.Uuid +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object UuidSerializer : KSerializer { + + private const val SERIAL_NAME = "com.radixdlt.sargon.Uuid" + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + serialName = SERIAL_NAME, + kind = PrimitiveKind.STRING + ) + + override fun serialize(encoder: Encoder, value: Uuid) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uuid { + return Uuid.fromString(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/BagOfBytesTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/BagOfBytesTest.kt index 8993cdba7..fd14ee5dd 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/BagOfBytesTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/BagOfBytesTest.kt @@ -1,10 +1,12 @@ package com.radixdlt.sargon +import com.radixdlt.sargon.extensions.bagOfBytesOf import com.radixdlt.sargon.extensions.hash import com.radixdlt.sargon.extensions.hex import com.radixdlt.sargon.extensions.hexToBagOfBytes import com.radixdlt.sargon.extensions.randomBagOfBytes import com.radixdlt.sargon.extensions.toBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray import com.radixdlt.sargon.samples.acedBagOfBytesSample import com.radixdlt.sargon.samples.appendingCafeSample import com.radixdlt.sargon.samples.appendingDeadbeefSample @@ -93,4 +95,17 @@ class BagOfBytesTest { } } + @Test + fun testRoundtrip() { + repeat(1000) { + val bagOfBytes = randomBagOfBytes(32) + val byteArray = bagOfBytes.toByteArray() + + assertEquals( + bagOfBytes, + bagOfBytesOf(byteArray) + ) + } + } + } \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/DisplayNameTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/DisplayNameTest.kt new file mode 100644 index 000000000..3ddaa7c7f --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/DisplayNameTest.kt @@ -0,0 +1,25 @@ +package com.radixdlt.sargon + +import com.radixdlt.sargon.extensions.init +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DisplayNameTest { + + @Test + fun testTrimsWhenLong() { + assertEquals( + "jkhfgasdkjhfgskdfghskdghfskdjh", + DisplayName.init("jkhfgasdkjhfgskdfghskdghfskdjhfgsdkjfhgasdkjhfgsdjkfghaskfhjsd").value, + ) + } + + @Test + fun testThrowsWhenEmpty() { + assertThrows { + DisplayName.init("") + } + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/FactorSourceTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/FactorSourceTest.kt index 836308f03..60a7d7565 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/FactorSourceTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/FactorSourceTest.kt @@ -1,7 +1,9 @@ package com.radixdlt.sargon import android.bluetooth.BluetoothClass.Device +import com.radixdlt.sargon.extensions.asGeneral import com.radixdlt.sargon.extensions.babylon +import com.radixdlt.sargon.extensions.id import com.radixdlt.sargon.extensions.isMain import com.radixdlt.sargon.extensions.kind import com.radixdlt.sargon.extensions.olympia @@ -9,6 +11,8 @@ import com.radixdlt.sargon.extensions.supportsBabylon import com.radixdlt.sargon.extensions.supportsOlympia import com.radixdlt.sargon.samples.Sample import com.radixdlt.sargon.samples.sample +import com.radixdlt.sargon.samples.sampleMainnet +import com.radixdlt.sargon.samples.sampleRandom import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -30,6 +34,105 @@ class FactorSourceTest : SampleTestable { FactorSourceKind.LEDGER_HQ_HARDWARE_WALLET, FactorSource.sample.other().kind ) + + assertEquals( + FactorSourceKind.TRUSTED_CONTACT, + trustedContact.kind + ) + + assertEquals( + FactorSourceKind.ARCULUS_CARD, + arculusCard.kind + ) + + assertEquals( + FactorSourceKind.OFF_DEVICE_MNEMONIC, + offDeviceMnemonic.kind + ) + + assertEquals( + FactorSourceKind.SECURITY_QUESTIONS, + sequrityQuestions.kind + ) + } + + @Test + fun testId() { + with(FactorSource.sample()) { + assertEquals( + id, + (this as FactorSource.Device).value.id.asGeneral() + ) + } + + with(FactorSource.sample.other()) { + assertEquals( + id, + (this as FactorSource.Ledger).value.id.asGeneral() + ) + } + + + with(trustedContact) { + assertEquals( + id, + value.id.asGeneral() + ) + } + + with(arculusCard) { + assertEquals( + id, + value.id.asGeneral() + ) + } + + with(offDeviceMnemonic) { + assertEquals( + id, + value.id.asGeneral() + ) + } + + with(sequrityQuestions) { + assertEquals( + id, + value.id.asGeneral() + ) + } + } + + @Test + fun testValuesAsGeneral() { + assertEquals( + FactorSource.sample(), + (FactorSource.sample() as FactorSource.Device).value.asGeneral() + ) + + assertEquals( + FactorSource.sample.other(), + (FactorSource.sample.other() as FactorSource.Ledger).value.asGeneral() + ) + + assertEquals( + trustedContact, + trustedContact.value.asGeneral() + ) + + assertEquals( + arculusCard, + arculusCard.value.asGeneral() + ) + + assertEquals( + offDeviceMnemonic, + offDeviceMnemonic.value.asGeneral() + ) + + assertEquals( + sequrityQuestions, + sequrityQuestions.value.asGeneral() + ) } @Test @@ -74,4 +177,99 @@ class FactorSourceTest : SampleTestable { assertTrue(factorSource.supportsOlympia) assertFalse(factorSource.supportsBabylon) } + + private val trustedContact = FactorSource.TrustedContact( + value = TrustedContactFactorSource( + id = FactorSourceIdFromAddress( + kind = FactorSourceKind.TRUSTED_CONTACT, body = AccountAddress.sampleMainnet() + ), + common = FactorSourceCommon( + cryptoParameters = FactorSourceCryptoParameters( + supportedCurves = listOf(Slip10Curve.CURVE25519), + supportedDerivationPathSchemes = listOf(DerivationPathScheme.CAP26) + ), + addedOn = Timestamp.now(), + lastUsedOn = Timestamp.now(), + flags = emptyList() + ), + contact = TrustedContactFactorSourceContact( + emailAddress = EmailAddress("mail@email.com"), + name = DisplayName("Trusted contact") + ) + ) + ) + + private val arculusCard = FactorSource.ArculusCard( + value = ArculusCardFactorSource( + id = FactorSourceIdFromHash( + kind = FactorSourceKind.ARCULUS_CARD, + body = Exactly32Bytes.sample() + ), + common = FactorSourceCommon( + cryptoParameters = FactorSourceCryptoParameters( + supportedCurves = listOf(Slip10Curve.CURVE25519), + supportedDerivationPathSchemes = listOf(DerivationPathScheme.CAP26) + ), + addedOn = Timestamp.now(), + lastUsedOn = Timestamp.now(), + flags = emptyList() + ), + hint = ArculusCardHint( + name = "My Arculus", + model = ArculusCardModel.ARCULUS_COLD_STORAGE_WALLET + ) + ) + ) + + private val offDeviceMnemonic = FactorSource.OffDeviceMnemonic( + value = OffDeviceMnemonicFactorSource( + id = FactorSourceIdFromHash( + kind = FactorSourceKind.ARCULUS_CARD, + body = Exactly32Bytes.sample() + ), + common = FactorSourceCommon( + cryptoParameters = FactorSourceCryptoParameters( + supportedCurves = listOf(Slip10Curve.CURVE25519), + supportedDerivationPathSchemes = listOf(DerivationPathScheme.CAP26) + ), + addedOn = Timestamp.now(), + lastUsedOn = Timestamp.now(), + flags = emptyList() + ), + hint = OffDeviceMnemonicHint( + displayName = DisplayName("My mnemonic stored somewhere") + ) + ) + ) + + private val sequrityQuestions = FactorSource.SecurityQuestions( + value = SecurityQuestionsNotProductionReadyFactorSource( + id = FactorSourceIdFromHash( + kind = FactorSourceKind.SECURITY_QUESTIONS, + body = Exactly32Bytes.sample() + ), + common = FactorSourceCommon( + cryptoParameters = FactorSourceCryptoParameters( + supportedCurves = listOf(Slip10Curve.CURVE25519), + supportedDerivationPathSchemes = listOf(DerivationPathScheme.CAP26) + ), + addedOn = Timestamp.now(), + lastUsedOn = Timestamp.now(), + flags = emptyList() + ), + sealedMnemonic = SecurityQuestionsSealedNotProductionReadyMnemonic( + securityQuestions = emptyList(), + kdfScheme = SecurityQuestionsNotProductionReadyKdfScheme.Version1( + v1 = SecurityQuestionsNotProductionReadyKdfSchemeVersion1( + kdfEncryptionKeysFromKeyExchangeKeys = SecurityQuestionsNotProductionReadyEncryptionKeysByDiffieHellmanFold(), + kdfKeyExchangesKeysFromQuestionsAndAnswers = SecurityQuestionsNotProductionReadyKeyExchangeKeysFromQandAsLowerTrimUtf8() + ) + ), + encryptionScheme = EncryptionScheme.Version1( + v1 = AesGcm256() + ), + encryptions = emptyList() + ), + ) + ) } \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/MnemonicWithPassphraseTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/MnemonicWithPassphraseTest.kt index 166310924..443c54470 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/MnemonicWithPassphraseTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/MnemonicWithPassphraseTest.kt @@ -80,7 +80,6 @@ class MnemonicWithPassphraseTest { MnemonicWithPassphrase.fromJson(invalidJson) } - val iOSJsonLike = mnemonicWithPassphraseToJsonBytes( MnemonicWithPassphrase( mnemonic = Mnemonic.init("remind index lift gun sleep inner double leopard exist sugar item whisper coast duty leopard law radar neutral odor tape finger position capital track"), diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/NetworkAntennaTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/NetworkAntennaTest.kt index d73fe6669..7e91fda0c 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/NetworkAntennaTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/NetworkAntennaTest.kt @@ -1,6 +1,6 @@ package com.radixdlt.sargon -import com.radixdlt.sargon.antenna.SargonNetworkingDriver +import com.radixdlt.sargon.os.driver.AndroidNetworkingDriver import com.radixdlt.sargon.extensions.compareTo import com.radixdlt.sargon.extensions.toDecimal192 import com.radixdlt.sargon.extensions.toHttpMethod @@ -19,7 +19,7 @@ class NetworkAntennaTest { @Test @Tag("IntegrationTests") fun testNetwork() = runBlocking { - val client = GatewayClient(SargonNetworkingDriver(okHttpClient), NetworkId.MAINNET) + val client = GatewayClient(AndroidNetworkingDriver(okHttpClient), NetworkId.MAINNET) val xrdBalance = client.xrdBalanceOfAccountOrZero(address = AccountAddress.sampleMainnet()) diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/ResultTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/ResultTest.kt index 6bf57adcc..4f38325ff 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/ResultTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/ResultTest.kt @@ -2,7 +2,9 @@ package com.radixdlt.sargon import com.radixdlt.sargon.extensions.mapError import com.radixdlt.sargon.extensions.then +import com.radixdlt.sargon.extensions.toUnit import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -61,4 +63,14 @@ class ResultTest { result.getOrThrow() } } + + @Test + fun testToUnit() { + val result = Result.success(10) + + assertInstanceOf( + Unit::class.java, + result.toUnit().getOrThrow() + ) + } } \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt new file mode 100644 index 000000000..8ddc800fe --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/SecureStorageKeyTest.kt @@ -0,0 +1,30 @@ +package com.radixdlt.sargon + +import com.radixdlt.sargon.extensions.identifier +import com.radixdlt.sargon.samples.sample +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SecureStorageKeyTest { + + @Test + fun testIdentifier() { + val factorSourceId = DeviceFactorSource.sample().id + val key = SecureStorageKey.DeviceFactorSourceMnemonic(factorSourceId = factorSourceId) + assertEquals( + "secure_storage_key_device_factor_source_device:f1a93d324dd0f2bff89963ab81ed6e0c2ee7e18c0827dc1d3576b2d9f26bbd0a", + key.identifier + ) + + assertEquals( + "secure_storage_key_host_id", + SecureStorageKey.HostId.identifier + ) + + assertEquals( + "secure_storage_key_profile_snapshot", + SecureStorageKey.ProfileSnapshot.identifier + ) + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/UnsafeStorageKeyTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/UnsafeStorageKeyTest.kt new file mode 100644 index 000000000..3ea281636 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/UnsafeStorageKeyTest.kt @@ -0,0 +1,18 @@ +package com.radixdlt.sargon + +import com.radixdlt.sargon.extensions.identifier +import com.radixdlt.sargon.samples.sample +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class UnsafeStorageKeyTest { + + @Test + fun testIdentifier() { + assertEquals( + "unsafe_storage_key_factor_source_user_has_written_down", + UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN.identifier + ) + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEntropyProviderDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEntropyProviderDriverTest.kt new file mode 100644 index 000000000..2f8b8a619 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEntropyProviderDriverTest.kt @@ -0,0 +1,20 @@ +package com.radixdlt.sargon.os.driver + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AndroidEntropyProviderDriverTest { + + private val sut = AndroidEntropyProviderDriver() + + @Test + fun testEntropy() { + val cases = 1000 + assertEquals( + cases, + List(cases) { + sut.generateSecureRandomBytes() + }.toSet().size + ) + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt new file mode 100644 index 000000000..66963d8b8 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidEventBusDriverTest.kt @@ -0,0 +1,55 @@ +package com.radixdlt.sargon.os.driver + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.radixdlt.sargon.Event +import com.radixdlt.sargon.EventNotification +import com.radixdlt.sargon.Profile +import com.radixdlt.sargon.Timestamp +import com.radixdlt.sargon.Uuid +import com.radixdlt.sargon.samples.sample +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AndroidEventBusDriverTest { + + private val sut = AndroidEventBusDriver() + + @Test + fun testProfileIsEmitted() = runTest { + val event = EventNotification( + id = Uuid.randomUUID(), + event = Event.Booted, + timestamp = Timestamp.now() + ) + + sut.events.test { + // First subscribe to event changes (this is a shared flow) then emit a value + sut.handleEventNotification(event) + + // Then assert values are received + assertEquals(event, awaitItem()) + } + } + + @Test + fun testMulticast() = runTest { + val event = EventNotification( + id = Uuid.randomUUID(), + event = Event.Booted, + timestamp = Timestamp.now() + ) + + turbineScope { + val firstSubscriber = sut.events.testIn(backgroundScope) + val secondSubscriber = sut.events.testIn(backgroundScope) + + sut.handleEventNotification(event) + + assertEquals(event, firstSubscriber.awaitItem()) + assertEquals(event, secondSubscriber.awaitItem()) + } + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt new file mode 100644 index 000000000..de594b4a6 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidLoggingDriverTest.kt @@ -0,0 +1,77 @@ +package com.radixdlt.sargon.os.driver + +import com.radixdlt.sargon.LogLevel +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import timber.log.Timber + +private typealias AndroidLogger = android.util.Log + +class AndroidLoggingDriverTest { + + private val logTree = TestTree() + + @BeforeEach + fun installLogger() { + Timber.plant(logTree) + } + + @AfterEach + fun removeLogger() { + Timber.uproot(logTree) + } + + @Test + fun `test logs emitted in correct level`() { + val input = listOf( + TestTree.Log(level = LogLevel.INFO, message = "info"), + TestTree.Log(level = LogLevel.TRACE, message = "verbose"), + TestTree.Log(level = LogLevel.WARN, message = "warn"), + TestTree.Log(level = LogLevel.ERROR, message = "error"), + TestTree.Log(level = LogLevel.DEBUG, message = "debug") + ) + val sut = AndroidLoggingDriver() + + input.forEach { log -> + sut.log(log.level, log.message) + } + + assertEquals( + input, + logTree.logs + ) + } + + private class TestTree: Timber.Tree() { + + private val _logs = mutableListOf() + + val logs: List + get() = _logs + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val level = when (priority) { + AndroidLogger.VERBOSE -> LogLevel.TRACE + AndroidLogger.INFO -> LogLevel.INFO + AndroidLogger.WARN -> LogLevel.WARN + AndroidLogger.DEBUG -> LogLevel.DEBUG + AndroidLogger.ERROR -> LogLevel.ERROR + else -> null + } + + if (level != null) { + _logs.add(Log(level, tag, message)) + } + } + + data class Log( + val level: LogLevel, + val tag: String? = null, + val message: String + ) + } + + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileChangeDriverTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileChangeDriverTest.kt new file mode 100644 index 000000000..75bb54085 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/driver/AndroidProfileChangeDriverTest.kt @@ -0,0 +1,43 @@ +package com.radixdlt.sargon.os.driver + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.radixdlt.sargon.Profile +import com.radixdlt.sargon.samples.sample +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AndroidProfileChangeDriverTest { + + private val sut = AndroidProfileChangeDriver() + + @Test + fun testProfileIsEmitted() = runTest { + val profile = Profile.sample() + + sut.profile.test { + // First subscribe to profile changes (this is a shared flow) then emit a value + sut.handleProfileChange(profile) + + // Then assert values are received + assertEquals(profile, awaitItem()) + } + } + + @Test + fun testMulticast() = runTest { + val profile = Profile.sample() + + turbineScope { + val firstSubscriber = sut.profile.testIn(backgroundScope) + val secondSubscriber = sut.profile.testIn(backgroundScope) + + sut.handleProfileChange(profile) + + assertEquals(profile, firstSubscriber.awaitItem()) + assertEquals(profile, secondSubscriber.awaitItem()) + } + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorageTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorageTest.kt index 5bc701eca..ca2e3b67e 100644 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorageTest.kt +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/radixconnect/RadixConnectSessionStorageTest.kt @@ -3,15 +3,19 @@ package com.radixdlt.sargon.os.radixconnect import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.radixdlt.sargon.SessionId import com.radixdlt.sargon.extensions.randomBagOfBytes -import com.radixdlt.sargon.os.storage.EncryptedPreferencesStorage import com.radixdlt.sargon.os.storage.EncryptionHelper import com.radixdlt.sargon.os.storage.KeySpec +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest import io.mockk.every +import io.mockk.mockk import io.mockk.mockkConstructor +import io.mockk.mockkObject import kotlinx.coroutines.Job import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -29,22 +33,22 @@ class RadixConnectSessionStorageTest { lateinit var tmpDir: File private val sut = RadixConnectSessionStorage( - storage = EncryptedPreferencesStorage( - datastore = PreferenceDataStoreFactory.create(scope = testScope) { - File(tmpDir, "radix_connect_session_storage.preferences_pb") - } - ) + dataStore = PreferenceDataStoreFactory.create(scope = testScope) { + File(tmpDir, "radix_connect_session_storage.preferences_pb") + } ) @Test fun testRoundtrip() = runTest(context = testDispatcher) { - mockkConstructor(KeySpec.RadixConnect::class) - every { anyConstructed().getOrGenerateSecretKey() } returns Result.success( + mockkObject(KeystoreAccessRequest.ForRadixConnect) + val keySpec = mockk() + every { keySpec.getOrGenerateSecretKey() } returns Result.success( SecretKeySpec( randomBagOfBytes(32).toUByteArray().toByteArray(), EncryptionHelper.AES_ALGORITHM ) ) + every { KeystoreAccessRequest.ForRadixConnect.keySpec } returns keySpec val sessionId = SessionId.randomUUID() val sessionBytes = randomBagOfBytes(32) @@ -56,19 +60,21 @@ class RadixConnectSessionStorageTest { @Test fun testGetNullDueToKeySpecException() = runTest(context = testDispatcher) { - mockkConstructor(KeySpec.RadixConnect::class) - every { anyConstructed().getOrGenerateSecretKey() } returns Result.success( + mockkObject(KeystoreAccessRequest.ForRadixConnect) + val keySpec = mockk() + every { keySpec.getOrGenerateSecretKey() } returns Result.success( SecretKeySpec( randomBagOfBytes(32).toUByteArray().toByteArray(), EncryptionHelper.AES_ALGORITHM ) ) + every { KeystoreAccessRequest.ForRadixConnect.keySpec } returns keySpec val sessionId = SessionId.randomUUID() val sessionBytes = randomBagOfBytes(32) sut.saveSession(sessionId, sessionBytes) - every { anyConstructed().getOrGenerateSecretKey() } returns Result.failure( + every { keySpec.getOrGenerateSecretKey() } returns Result.failure( RuntimeException("Some Error") ) assertNull(sut.loadSession(sessionId)) diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptedPreferencesStorageTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptedPreferencesStorageTest.kt deleted file mode 100644 index e69af477c..000000000 --- a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/EncryptedPreferencesStorageTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.radixdlt.sargon.os.storage - -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.stringPreferencesKey -import com.radixdlt.sargon.extensions.randomBagOfBytes -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.Job -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.api.io.TempDir -import java.io.File -import javax.crypto.spec.SecretKeySpec - -@OptIn(ExperimentalUnsignedTypes::class) -class EncryptedPreferencesStorageTest { - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher + Job()) - - private val mockKeySpec = mockk().apply { - every { getOrGenerateSecretKey() } returns Result.success( - SecretKeySpec( - randomBagOfBytes(32).toUByteArray().toByteArray(), - EncryptionHelper.AES_ALGORITHM - ) - ) - } - - @field:TempDir - lateinit var tmpDir: File - - private val sut = EncryptedPreferencesStorage( - datastore = PreferenceDataStoreFactory.create(scope = testScope) { - File(tmpDir, "test.preferences_pb") - } - ) - - @Test - fun testRoundtrip() = runTest(context = testDispatcher) { - val pair = "some-key" to "Some value to save" - sut.set(stringPreferencesKey(pair.first), pair.second, mockKeySpec).getOrThrow() - - val value = sut.get(stringPreferencesKey(pair.first), mockKeySpec).getOrThrow() - assertEquals(pair.second, value) - } - - @Test - fun testGetWithNoSetReturnsNull() = runTest(context = testDispatcher) { - val value = sut.get(stringPreferencesKey("some-other-key"), mockKeySpec).getOrThrow() - assertNull(value) - } - - @Test - fun testGetAfterRemovingKeyReturnsNull() = runTest(context = testDispatcher) { - val pair = "some-key" to "Some value to save" - sut.set(stringPreferencesKey(pair.first), pair.second, mockKeySpec).getOrThrow() - - assertEquals(sut.get(stringPreferencesKey(pair.first), mockKeySpec).getOrThrow(), pair.second) - - sut.remove(stringPreferencesKey(pair.first)) - - val value = sut.get(stringPreferencesKey("some-other-key"), mockKeySpec).getOrThrow() - assertNull(value) - } - - @Test - fun testEncryptErrorOnSet() = runTest(context = testDispatcher) { - every { mockKeySpec.getOrGenerateSecretKey() } returns Result.failure(RuntimeException("Some Error")) - - val pair = "some-key" to "Some value to save" - assertThrows { - sut.set(stringPreferencesKey(pair.first), pair.second, mockKeySpec).getOrThrow() - } - } - -} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt new file mode 100644 index 000000000..540084be5 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/KeystoreAccessRequestTest.kt @@ -0,0 +1,63 @@ +package com.radixdlt.sargon.os.storage + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class KeystoreAccessRequestTest { + + @Test + fun testSpecsOfProfile() = runTest { + val request = KeystoreAccessRequest.ForProfile + + assertInstanceOf(KeySpec.Profile::class.java, request.keySpec) + + try { + request.requestAuthorization().getOrThrow() + } catch (exception: Exception) { + assert(false) { "requestAuthorization for Profile should succeed but didn't" } + } + } + + @Test + fun testSpecsOfRadixConnect() = runTest { + val request = KeystoreAccessRequest.ForRadixConnect + + assertInstanceOf(KeySpec.RadixConnect::class.java, request.keySpec) + + try { + request.requestAuthorization().getOrThrow() + } catch (exception: Exception) { + assert(false) { "requestAuthorization for Radix Connect should succeed but didn't" } + } + } + + @Test + fun testSpecsOfMnemonic() = runTest { + val request = KeystoreAccessRequest.ForMnemonic( + onRequestAuthorization = { Result.success(Unit) } + ) + assertInstanceOf(KeySpec.Mnemonic::class.java, request.keySpec) + + try { + request.requestAuthorization().getOrThrow() + } catch (exception: Exception) { + assert(false) { "requestAuthorization for Mnemonic should succeed but didn't" } + } + + val failingRequest = KeystoreAccessRequest.ForMnemonic( + onRequestAuthorization = { Result.failure(RuntimeException("An error")) } + ) + + try { + failingRequest.requestAuthorization().getOrThrow() + assert(false) { "requestAuthorization for failing access to Mnemonic should fail but succeeded" } + } catch (exception: Exception) { + assert(true) { "requestAuthorization for failing access to Mnemonic should fail" } + } + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt new file mode 100644 index 000000000..58560da8f --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/StorageUtilsTest.kt @@ -0,0 +1,192 @@ +package com.radixdlt.sargon.os.storage + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.extensions.randomBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import javax.crypto.spec.SecretKeySpec + +class StorageUtilsTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher + Job()) + + @field:TempDir + lateinit var tmpDir: File + + private val sut = PreferenceDataStoreFactory.create(scope = testScope) { + File(tmpDir, "test.preferences_pb") + } + + + @Test + fun testReadWhenNullValueWithoutAuhotize() = runTest(context = testDispatcher) { + val value = sut.read( + key = stringPreferencesKey("a_key"), + ) + + assertNull(value.getOrThrow()) + } + + @Test + fun testRoundtripWithoutAccessRequest() = runTest(context = testDispatcher) { + sut.write( + key = stringPreferencesKey("a_key"), + value = "a value" + ) + + val value = sut.read( + key = stringPreferencesKey("a_key"), + ) + + assertEquals( + "a value", + value.getOrThrow() + ) + } + + @Test + fun testReadNullWhenNoValueWhenAuthorized() = runTest(context = testDispatcher) { + val value = sut.read( + key = stringPreferencesKey("a_key"), + keystoreAccessRequest = KeystoreAccessRequest.ForProfile + ) + + assertNull(value.getOrThrow()) + } + + @Test + fun testRoundtripWhenAlwaysAuthorized() = runTest(context = testDispatcher) { + mockProfileAccessRequest() + + sut.write( + key = stringPreferencesKey("a_key"), + value = "a value", + keystoreAccessRequest = KeystoreAccessRequest.ForProfile + ).getOrThrow() + + val value = sut.read( + key = stringPreferencesKey("a_key"), + keystoreAccessRequest = KeystoreAccessRequest.ForProfile + ) + + assertEquals( + "a value", + value.getOrThrow() + ) + } + + @Test + fun testWriteFailWhenNotAuthorized() = runTest(context = testDispatcher) { + val mnemonicAccessRequest = mockMnemonicRequest(onAuthorizeWhenRequested = false) + + val result = sut.write( + key = stringPreferencesKey("a_key"), + value = "a value", + keystoreAccessRequest = mnemonicAccessRequest + ) + + assertTrue(result.isFailure) + } + + @Test + fun testWriteSucceedWhenAuthorized() = runTest(context = testDispatcher) { + val mnemonicAccessRequest = mockMnemonicRequest(onAuthorizeWhenRequested = true) + + val result = sut.write( + key = stringPreferencesKey("a_key"), + value = "a value", + keystoreAccessRequest = mnemonicAccessRequest + ) + + assertTrue(result.isSuccess) + } + + @Test + fun testRoundtripFailWhenNotAuthorizedOnRead() = runTest(context = testDispatcher) { + val mnemonicAccessRequestApproved = mockMnemonicRequest(onAuthorizeWhenRequested = true) + + val writeResult = sut.write( + key = stringPreferencesKey("a_key"), + value = "a value", + keystoreAccessRequest = mnemonicAccessRequestApproved + ) + assertTrue(writeResult.isSuccess) + + val mnemonicAccessRequestDenied = mockMnemonicRequest(onAuthorizeWhenRequested = false) + val readResult = sut.read( + key = stringPreferencesKey("a_key"), + keystoreAccessRequest = mnemonicAccessRequestDenied + ) + assertTrue(readResult.isFailure) + } + + @Test + fun testRoundtripWhenAuthorized() = runTest(context = testDispatcher) { + val mnemonicAccessRequest = mockMnemonicRequest(onAuthorizeWhenRequested = true) + + val writeResult = sut.write( + key = stringPreferencesKey("a_key"), + value = "a value", + keystoreAccessRequest = mnemonicAccessRequest + ) + assertTrue(writeResult.isSuccess) + + val readResult = sut.read( + key = stringPreferencesKey("a_key"), + keystoreAccessRequest = mnemonicAccessRequest + ) + assertEquals( + "a value", + readResult.getOrThrow() + ) + } + + private fun mockProfileAccessRequest() { + val mockKeySpec = mockk() + every { mockKeySpec.getOrGenerateSecretKey() } returns Result.success( + SecretKeySpec( + randomBagOfBytes(32).toByteArray(), + EncryptionHelper.AES_ALGORITHM + ) + ) + + mockkObject(KeystoreAccessRequest.ForProfile) + every { KeystoreAccessRequest.ForProfile.keySpec } returns mockKeySpec + } + + private fun mockMnemonicRequest( + onAuthorizeWhenRequested: Boolean + ): KeystoreAccessRequest.ForMnemonic { + val mockKeySpec = mockk() + every { mockKeySpec.getOrGenerateSecretKey() } returns Result.success( + SecretKeySpec( + randomBagOfBytes(32).toByteArray(), + EncryptionHelper.AES_ALGORITHM + ) + ) + + val mockAccessRequest = mockk() + every { mockAccessRequest.keySpec } returns mockKeySpec + coEvery { mockAccessRequest.requestAuthorization() } returns if (onAuthorizeWhenRequested) { + Result.success(Unit) + } else { + Result.failure(RuntimeException("Not allowed to authorized in this unit test")) + } + return mockAccessRequest + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt new file mode 100644 index 000000000..c4cdf03e3 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ByteArrayKeyMappingTest.kt @@ -0,0 +1,102 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.UnsafeStorageKey +import com.radixdlt.sargon.extensions.randomBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.os.storage.EncryptionHelper +import com.radixdlt.sargon.os.storage.KeySpec +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import javax.crypto.spec.SecretKeySpec + +class ByteArrayKeyMappingTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher + Job()) + + @field:TempDir + lateinit var tmpDir: File + + private val storage = PreferenceDataStoreFactory.create(scope = testScope) { + File(tmpDir, "test.preferences_pb") + } + + @Test + fun testUnsafeKeyRoundtrip() = runTest(context = testDispatcher) { + val key = UnsafeStorageKey.FACTOR_SOURCE_USER_HAS_WRITTEN_DOWN + + val sut = ByteArrayKeyMapping( + key = key, + storage = storage + ) + + val bytesToStore = randomBagOfBytes(32) + val writeResult = sut.write(bytesToStore) + assertTrue(writeResult.isSuccess) + + val bytesRestored = sut.read() + assertEquals( + bytesToStore, + bytesRestored.getOrThrow() + ) + + sut.remove() + val bytesRestoredAfterRemove = sut.read() + assertNull(bytesRestoredAfterRemove.getOrThrow()) + } + + @Test + fun testSecureStorageKeyRoundtrip() = runTest(context = testDispatcher) { + // Even thought profile snapshot does not store data in byte array, + // it is just used to facilitate the test + val key = SecureStorageKey.ProfileSnapshot + mockProfileAccessRequest() + + val sut = ByteArrayKeyMapping( + key = key, + keystoreAccessRequest = KeystoreAccessRequest.ForProfile, + storage = storage + ) + + val bytesToStore = randomBagOfBytes(32) + val writeResult = sut.write(bytesToStore) + assertTrue(writeResult.isSuccess) + + val bytesRestored = sut.read() + assertEquals( + bytesToStore, + bytesRestored.getOrThrow() + ) + + sut.remove() + val bytesRestoredAfterRemove = sut.read() + assertNull(bytesRestoredAfterRemove.getOrThrow()) + } + + private fun mockProfileAccessRequest() { + val mockKeySpec = mockk() + every { mockKeySpec.getOrGenerateSecretKey() } returns Result.success( + SecretKeySpec( + randomBagOfBytes(32).toByteArray(), + EncryptionHelper.AES_ALGORITHM + ) + ) + + mockkObject(KeystoreAccessRequest.ForProfile) + every { KeystoreAccessRequest.ForProfile.keySpec } returns mockKeySpec + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/DeviceFactorSourceMnemonicKeyMappingTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/DeviceFactorSourceMnemonicKeyMappingTest.kt new file mode 100644 index 000000000..8e7887b85 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/DeviceFactorSourceMnemonicKeyMappingTest.kt @@ -0,0 +1,125 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.Exactly32Bytes +import com.radixdlt.sargon.FactorSourceId +import com.radixdlt.sargon.FactorSourceIdFromHash +import com.radixdlt.sargon.FactorSourceKind +import com.radixdlt.sargon.MnemonicWithPassphrase +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.extensions.fromJson +import com.radixdlt.sargon.extensions.hex +import com.radixdlt.sargon.extensions.randomBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.mnemonicWithPassphraseToJsonBytes +import com.radixdlt.sargon.newMnemonicWithPassphraseFromJsonBytes +import com.radixdlt.sargon.os.driver.BiometricAuthorizationDriver +import com.radixdlt.sargon.os.storage.EncryptionHelper +import com.radixdlt.sargon.os.storage.KeySpec +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.samples.sample +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import javax.crypto.spec.SecretKeySpec + +class DeviceFactorSourceMnemonicKeyMappingTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher + Job()) + + private val mnemonicWithPassphrase = MnemonicWithPassphrase.sample() + + @field:TempDir + lateinit var tmpDir: File + + private val storage = PreferenceDataStoreFactory.create(scope = testScope) { + File(tmpDir, "test.preferences_pb") + } + + @Test + fun testRoundtrip() = runTest(context = testDispatcher) { + val driver = TestBiometricAuthorizationDriver(shouldAuthorize = true) + val mockedMnemonicRequest = mockMnemonicRequest(driver) + mockkConstructor(KeystoreAccessRequest.ForMnemonic::class) + every { anyConstructed().keySpec } returns mockedMnemonicRequest.keySpec + coEvery { anyConstructed().requestAuthorization() } returns Result.success(Unit) + + val factorSourceId = FactorSourceIdFromHash( + kind = FactorSourceKind.DEVICE, + body = Exactly32Bytes.sample() + ) + val sut = DeviceFactorSourceMnemonicKeyMapping( + key = SecureStorageKey.DeviceFactorSourceMnemonic( + factorSourceId = factorSourceId + ), + encryptedStorage = storage, + biometricAuthorizationDriver = driver + ) + + val writeResult = sut.write(mnemonicWithPassphraseToJsonBytes(mnemonicWithPassphrase)) + assertTrue(writeResult.isSuccess) + + val readResult = sut.read() + assertEquals( + mnemonicWithPassphrase, + newMnemonicWithPassphraseFromJsonBytes(readResult.getOrThrow()!!) + ) + + // Tests a read directly from storage + // In order to also assert the name of the key is "mnemonic" for compatibility + val readDirectlyFromStorage = storage.read( + key = stringPreferencesKey("mnemonic${factorSourceId.body.hex}"), + keystoreAccessRequest = mockedMnemonicRequest + ) + assertEquals( + mnemonicWithPassphrase, + MnemonicWithPassphrase.fromJson(readDirectlyFromStorage.getOrThrow()!!) + ) + + val removeResult = sut.remove() + assertTrue(removeResult.isSuccess) + val readResultWhenRemoved = sut.read() + assertNull(readResultWhenRemoved.getOrThrow()) + } + + private class TestBiometricAuthorizationDriver( + private val shouldAuthorize: Boolean + ): BiometricAuthorizationDriver { + override suspend fun authorize(): Result = if (shouldAuthorize) { + Result.success(Unit) + } else { + Result.failure(RuntimeException("Authorization denied in this unit test")) + } + } + + private fun mockMnemonicRequest( + driver: BiometricAuthorizationDriver + ): KeystoreAccessRequest.ForMnemonic { + val mockKeySpec = mockk() + every { mockKeySpec.getOrGenerateSecretKey() } returns Result.success( + SecretKeySpec( + randomBagOfBytes(32).toByteArray(), + EncryptionHelper.AES_ALGORITHM + ) + ) + + val mockAccessRequest = mockk() + every { mockAccessRequest.keySpec } returns mockKeySpec + coEvery { mockAccessRequest.requestAuthorization() } coAnswers { driver.authorize() } + return mockAccessRequest + } +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/HostIdKeyMappingTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/HostIdKeyMappingTest.kt new file mode 100644 index 000000000..a78eecdf9 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/HostIdKeyMappingTest.kt @@ -0,0 +1,79 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.HostId +import com.radixdlt.sargon.MnemonicWithPassphrase +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.hostIdToJsonBytes +import com.radixdlt.sargon.newHostIdFromJsonBytes +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.samples.sample +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class HostIdKeyMappingTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher + Job()) + + private val mnemonicWithPassphrase = MnemonicWithPassphrase.sample() + + @field:TempDir + lateinit var tmpDir: File + + private val storage = PreferenceDataStoreFactory.create(scope = testScope) { + File(tmpDir, "test.preferences_pb") + } + + @Test + fun testRoundtrip() = runTest(context = testDispatcher) { + val hostId = HostId.sample() + + val sut = HostIdKeyMapping( + key = SecureStorageKey.HostId, + deviceStorage = storage + ) + + val writeResult = sut.write(hostIdToJsonBytes(hostId)) + assertTrue(writeResult.isSuccess) + + val readResult = sut.read() + assertEquals( + hostId, + newHostIdFromJsonBytes(readResult.getOrThrow()!!) + ) + + // Test the key is the correct one by testing a read directly through storage + val readDirectlyFromStorageResult = storage.read( + key = stringPreferencesKey("key_device_info") + ) + assertEquals( + HostIdAndroidEntry( + id = hostId.id, + date = hostId.generatedAt + ), + HostIdAndroidEntry.fromJsonString(readDirectlyFromStorageResult.getOrThrow()!!) + ) + assertEquals( + hostId, + // Also test the dance between android specific mapping and sargon mapping are + // producing the expected result + HostIdAndroidEntry.fromJsonString(readDirectlyFromStorageResult.getOrThrow()!!).toHostId() + ) + + val removeResult = sut.remove() + assertTrue(removeResult.isSuccess) + val readResultAfterRemove = sut.read() + assertNull(readResultAfterRemove.getOrThrow()) + } + +} \ No newline at end of file diff --git a/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt new file mode 100644 index 000000000..805cf2044 --- /dev/null +++ b/jvm/sargon-android/src/test/java/com/radixdlt/sargon/os/storage/key/ProfileSnapshotKeyMappingTest.kt @@ -0,0 +1,94 @@ +package com.radixdlt.sargon.os.storage.key + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.radixdlt.sargon.Profile +import com.radixdlt.sargon.SecureStorageKey +import com.radixdlt.sargon.extensions.randomBagOfBytes +import com.radixdlt.sargon.extensions.string +import com.radixdlt.sargon.extensions.toBagOfBytes +import com.radixdlt.sargon.extensions.toByteArray +import com.radixdlt.sargon.newProfileFromJsonString +import com.radixdlt.sargon.os.storage.EncryptionHelper +import com.radixdlt.sargon.os.storage.KeySpec +import com.radixdlt.sargon.os.storage.KeystoreAccessRequest +import com.radixdlt.sargon.os.storage.read +import com.radixdlt.sargon.profileToJsonString +import com.radixdlt.sargon.samples.sample +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import javax.crypto.spec.SecretKeySpec + +class ProfileSnapshotKeyMappingTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher + Job()) + + private val profile = Profile.sample() + + @field:TempDir + lateinit var tmpDir: File + + private val storage = PreferenceDataStoreFactory.create(scope = testScope) { + File(tmpDir, "test.preferences_pb") + } + + @Test + fun testRoundtrip() = runTest(context = testDispatcher) { + mockProfileAccessRequest() + + val sut = ProfileSnapshotKeyMapping( + key = SecureStorageKey.ProfileSnapshot, + encryptedStorage = storage + ) + + val writeResult = sut.write(profileToJsonString(profile, false).toByteArray().toBagOfBytes()) + assertTrue(writeResult.isSuccess) + + val readResult = sut.read() + assertEquals( + profile, + newProfileFromJsonString(readResult.getOrThrow()!!.string) + ) + + // Tests a read directly from storage + // In order to also assert the name of the key is "profile_preferences_key" for compatibility + val readDirectlyFromStorage = storage.read( + key = stringPreferencesKey("profile_preferences_key"), + keystoreAccessRequest = KeystoreAccessRequest.ForProfile + ) + assertEquals( + profile, + newProfileFromJsonString(readDirectlyFromStorage.getOrThrow()!!) + ) + + val removeResult = sut.remove() + assertTrue(removeResult.isSuccess) + val readResultWhenRemoved = sut.read() + assertNull(readResultWhenRemoved.getOrThrow()) + } + + private fun mockProfileAccessRequest() { + val mockKeySpec = mockk() + every { mockKeySpec.getOrGenerateSecretKey() } returns Result.success( + SecretKeySpec( + randomBagOfBytes(32).toByteArray(), + EncryptionHelper.AES_ALGORITHM + ) + ) + + mockkObject(KeystoreAccessRequest.ForProfile) + every { KeystoreAccessRequest.ForProfile.keySpec } returns mockKeySpec + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 2843aac3f..c9b26c01a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ pub mod prelude { pub(crate) use std::sync::{Arc, RwLock}; pub(crate) use strum::FromRepr; + pub(crate) use strum::IntoEnumIterator; pub(crate) use url::Url; pub(crate) use uuid::Uuid; diff --git a/src/profile/supporting_types/host_id_uniffi_fn.rs b/src/profile/supporting_types/host_id_uniffi_fn.rs index baa8144df..c83ac0793 100644 --- a/src/profile/supporting_types/host_id_uniffi_fn.rs +++ b/src/profile/supporting_types/host_id_uniffi_fn.rs @@ -1,5 +1,7 @@ use crate::prelude::*; +json_data_convertible!(HostId); + #[uniffi::export] pub fn new_host_id_sample() -> HostId { HostId::sample() diff --git a/src/system/clients/client/mod.rs b/src/system/clients/client/mod.rs index 4182f77b1..6c69dbe51 100644 --- a/src/system/clients/client/mod.rs +++ b/src/system/clients/client/mod.rs @@ -3,6 +3,7 @@ mod event_bus_client; mod file_system_client; mod host_info_client; mod http_client; +mod profile_change_client; mod secure_storage_client; mod unsafe_storage_client; @@ -11,5 +12,6 @@ pub use event_bus_client::*; pub use file_system_client::*; pub use host_info_client::*; pub use http_client::*; +pub use profile_change_client::*; pub use secure_storage_client::*; pub use unsafe_storage_client::*; diff --git a/src/system/clients/client/profile_change_client/mod.rs b/src/system/clients/client/profile_change_client/mod.rs new file mode 100644 index 000000000..b65050a5d --- /dev/null +++ b/src/system/clients/client/profile_change_client/mod.rs @@ -0,0 +1,3 @@ +mod profile_change_client; + +pub use profile_change_client::*; diff --git a/src/system/clients/client/profile_change_client/profile_change_client.rs b/src/system/clients/client/profile_change_client/profile_change_client.rs new file mode 100644 index 000000000..f09ff2a68 --- /dev/null +++ b/src/system/clients/client/profile_change_client/profile_change_client.rs @@ -0,0 +1,18 @@ +use crate::prelude::*; + +#[derive(Debug)] +pub struct ProfileChangeClient { + driver: Arc, +} + +impl ProfileChangeClient { + pub(crate) fn new(driver: Arc) -> Self { + Self { driver } + } +} + +impl ProfileChangeClient { + pub async fn emit(&self, changed_profile: Profile) { + self.driver.handle_profile_change(changed_profile).await + } +} diff --git a/src/system/clients/client/secure_storage_client/secure_storage_client.rs b/src/system/clients/client/secure_storage_client/secure_storage_client.rs index daec8bf66..8cd919f42 100644 --- a/src/system/clients/client/secure_storage_client/secure_storage_client.rs +++ b/src/system/clients/client/secure_storage_client/secure_storage_client.rs @@ -91,75 +91,25 @@ impl SecureStorageClient { // Profile CR(U)D //====== - /// Loads the active Profile if any, by first loading the active - /// profile id. - pub async fn load_active_profile(&self) -> Result> { - debug!("Loading active profile"); - let Some(id) = self.load_active_profile_id().await? else { - trace!("Found no active profile id"); - return Ok(None); - }; - self.load_profile_with_id(id).await.map(Some) - } - - /// Loads the Profile with the given `profile_id`. - pub async fn load_profile_with_id( - &self, - profile_id: ProfileID, - ) -> Result { - debug!("Loading profile profile with id: {}", profile_id); - self.load_or( - SecureStorageKey::ProfileSnapshot { profile_id }, - CommonError::UnableToLoadProfileFromSecureStorage { profile_id }, - ) - .await - .inspect(|_| debug!("Loaded profile")) - .inspect_err(|e| error!("Failed to load profile, error {e}")) - } - - /// Loads the active ProfileID if any - pub async fn load_active_profile_id(&self) -> Result> { - trace!("Loading active profile id"); - self.load(SecureStorageKey::ActiveProfileID).await - } - - /// Save `profile` and saves its id as active profile id - pub async fn save_profile_and_active_profile_id( - &self, - profile: &Profile, - ) -> Result<()> { - debug!( - "Saving profile, id: {}, and setting it as active", - &profile.id() - ); - self.save_profile(profile).await?; - self.save_active_profile_id(profile.id()).await + /// Loads the Profile. + pub async fn load_profile(&self) -> Result> { + debug!("Loading profile"); + self.load(SecureStorageKey::ProfileSnapshot) + .await + .inspect(|_| debug!("Loaded profile")) + .inspect_err(|e| error!("Failed to load profile, error {e}")) } /// Save `profile` pub async fn save_profile(&self, profile: &Profile) -> Result<()> { let profile_id = profile.id(); debug!("Saving profile with id: {}", profile_id); - self.save(SecureStorageKey::ProfileSnapshot { profile_id }, profile) + self.save(SecureStorageKey::ProfileSnapshot, profile) .await .inspect(|_| debug!("Saved profile with id {}", profile_id)) .inspect_err(|e| error!("Failed to save profile, error {e}")) } - /// Save `profile_id` as the active profile id - pub async fn save_active_profile_id( - &self, - profile_id: ProfileID, - ) -> Result<()> { - debug!("Saving active profile id: {}", profile_id); - self.save(SecureStorageKey::ActiveProfileID, &profile_id) - .await - .inspect(|_| debug!("Saved active profile id")) - .inspect_err(|e| { - error!("Failed to save active profile id, error {e}") - }) - } - //====== // HostId CR(U)D //====== @@ -247,16 +197,7 @@ impl SecureStorageClient { pub async fn delete_profile(&self, id: ProfileID) -> Result<()> { warn!("Deleting profile with id: {}", id); self.driver - .delete_data_for_key(SecureStorageKey::ProfileSnapshot { - profile_id: id, - }) - .await - } - - pub async fn delete_active_profile_id(&self) -> Result<()> { - warn!("Deleting active profile id"); - self.driver - .delete_data_for_key(SecureStorageKey::ActiveProfileID) + .delete_data_for_key(SecureStorageKey::ProfileSnapshot) .await } } @@ -286,42 +227,21 @@ mod tests { async fn load_ok_when_none() { let sut = make_sut(); assert_eq!( - sut.load::(SecureStorageKey::ActiveProfileID).await, + sut.load::(SecureStorageKey::ProfileSnapshot).await, Ok(None) ); } - #[actix_rt::test] - async fn load_fail_to_deserialize_json() { - let sut = make_sut(); - - assert!(sut - .save( - SecureStorageKey::ActiveProfileID, - &0u8, // obviously a u8 is not a Profile - ) - .await - .is_ok()); - assert_eq!( - sut.load::(SecureStorageKey::ActiveProfileID).await, - Err(CommonError::FailedToDeserializeJSONToValue { - json_byte_count: 1, - type_name: "Profile".to_owned(), - serde_message: "invalid type: integer `0`, expected struct Profile at line 1 column 1".to_owned(), - }) - ); - } - #[actix_rt::test] async fn load_successful() { let sut = make_sut(); assert!(sut - .save(SecureStorageKey::ActiveProfileID, &Profile::sample()) + .save(SecureStorageKey::ProfileSnapshot, &Profile::sample()) .await .is_ok()); assert_eq!( - sut.load::(SecureStorageKey::ActiveProfileID).await, + sut.load::(SecureStorageKey::ProfileSnapshot).await, Ok(Some(Profile::sample())) ); } @@ -331,12 +251,12 @@ mod tests { let sut = make_sut(); assert!(sut - .save(SecureStorageKey::ActiveProfileID, &Profile::sample()) + .save(SecureStorageKey::ProfileSnapshot, &Profile::sample()) .await .is_ok()); assert_eq!( sut.load_unwrap_or::( - SecureStorageKey::ActiveProfileID, + SecureStorageKey::ProfileSnapshot, Profile::sample_other() ) .await, @@ -350,7 +270,7 @@ mod tests { assert_eq!( sut.load_unwrap_or::( - SecureStorageKey::ActiveProfileID, + SecureStorageKey::ProfileSnapshot, Profile::sample_other() ) .await, @@ -443,7 +363,7 @@ mod tests { let (sut, _) = SecureStorageClient::ephemeral(); assert_eq!( sut.save( - SecureStorageKey::ActiveProfileID, + SecureStorageKey::ProfileSnapshot, &AlwaysFailSerialize {} ) .await, diff --git a/src/system/clients/clients.rs b/src/system/clients/clients.rs index 2f4329d9e..3d4c64aef 100644 --- a/src/system/clients/clients.rs +++ b/src/system/clients/clients.rs @@ -9,6 +9,7 @@ pub struct Clients { pub unsafe_storage: UnsafeStorageClient, pub file_system: FileSystemClient, pub event_bus: EventBusClient, + pub profile_change: ProfileChangeClient, } impl Clients { @@ -22,6 +23,8 @@ impl Clients { UnsafeStorageClient::new(drivers.unsafe_storage.clone()); let file_system = FileSystemClient::new(drivers.file_system.clone()); let event_bus = EventBusClient::new(drivers.event_bus.clone()); + let profile_change = + ProfileChangeClient::new(drivers.profile_change_driver.clone()); Self { host, secure_storage, @@ -30,6 +33,7 @@ impl Clients { unsafe_storage, file_system, event_bus, + profile_change, } } diff --git a/src/system/drivers/drivers.rs b/src/system/drivers/drivers.rs index a5798106a..c4d4fed2f 100644 --- a/src/system/drivers/drivers.rs +++ b/src/system/drivers/drivers.rs @@ -10,6 +10,7 @@ pub struct Drivers { pub event_bus: Arc, pub file_system: Arc, pub unsafe_storage: Arc, + pub profile_change_driver: Arc, } #[uniffi::export] @@ -25,6 +26,7 @@ impl Drivers { event_bus: Arc, file_system: Arc, unsafe_storage: Arc, + profile_change_driver: Arc, ) -> Arc { Arc::new(Self { networking, @@ -35,6 +37,7 @@ impl Drivers { event_bus, file_system, unsafe_storage, + profile_change_driver, }) } } @@ -51,6 +54,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -64,6 +68,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -79,6 +84,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -94,6 +100,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -107,6 +114,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -120,6 +128,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -133,6 +142,7 @@ impl Drivers { event_bus, RustFileSystemDriver::new(), EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -148,6 +158,7 @@ impl Drivers { RustEventBusDriver::new(), file_system, EphemeralUnsafeStorage::new(), + RustProfileChangeDriver::new(), ) } @@ -163,6 +174,7 @@ impl Drivers { RustEventBusDriver::new(), RustFileSystemDriver::new(), unsafe_storage, + RustProfileChangeDriver::new(), ) } } diff --git a/src/system/drivers/mod.rs b/src/system/drivers/mod.rs index 849788d03..f085da09c 100644 --- a/src/system/drivers/mod.rs +++ b/src/system/drivers/mod.rs @@ -5,6 +5,7 @@ mod file_system_driver; mod host_info_driver; mod logging_driver; mod networking_driver; +mod profile_change_driver; mod secure_storage_driver; mod unsafe_storage_driver; @@ -15,5 +16,6 @@ pub use file_system_driver::*; pub use host_info_driver::*; pub use logging_driver::*; pub use networking_driver::*; +pub use profile_change_driver::*; pub use secure_storage_driver::*; pub use unsafe_storage_driver::*; diff --git a/src/system/drivers/profile_change_driver/mod.rs b/src/system/drivers/profile_change_driver/mod.rs new file mode 100644 index 000000000..db99169c1 --- /dev/null +++ b/src/system/drivers/profile_change_driver/mod.rs @@ -0,0 +1,5 @@ +mod profile_change_driver; +mod support; + +pub use profile_change_driver::*; +pub use support::*; diff --git a/src/system/drivers/profile_change_driver/profile_change_driver.rs b/src/system/drivers/profile_change_driver/profile_change_driver.rs new file mode 100644 index 000000000..775b2cb79 --- /dev/null +++ b/src/system/drivers/profile_change_driver/profile_change_driver.rs @@ -0,0 +1,7 @@ +use crate::prelude::*; + +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait ProfileChangeDriver: Send + Sync + std::fmt::Debug { + async fn handle_profile_change(&self, changed_profile: Profile); +} diff --git a/src/system/drivers/profile_change_driver/support/mod.rs b/src/system/drivers/profile_change_driver/support/mod.rs new file mode 100644 index 000000000..9abea5028 --- /dev/null +++ b/src/system/drivers/profile_change_driver/support/mod.rs @@ -0,0 +1,3 @@ +mod test; + +pub use test::*; diff --git a/src/system/drivers/profile_change_driver/support/test/mod.rs b/src/system/drivers/profile_change_driver/support/test/mod.rs new file mode 100644 index 000000000..977e16559 --- /dev/null +++ b/src/system/drivers/profile_change_driver/support/test/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +mod rust_profile_change_driver; + +#[cfg(test)] +pub use rust_profile_change_driver::*; diff --git a/src/system/drivers/profile_change_driver/support/test/rust_profile_change_driver.rs b/src/system/drivers/profile_change_driver/support/test/rust_profile_change_driver.rs new file mode 100644 index 000000000..ccd7f57fe --- /dev/null +++ b/src/system/drivers/profile_change_driver/support/test/rust_profile_change_driver.rs @@ -0,0 +1,36 @@ +#![cfg(test)] + +use crate::prelude::*; +use std::sync::RwLock; + +#[derive(Debug)] +pub struct RustProfileChangeDriver { + recorded: RwLock>, + spy: fn(Profile) -> (), +} + +#[async_trait::async_trait] +impl ProfileChangeDriver for RustProfileChangeDriver { + async fn handle_profile_change(&self, changed_profile: Profile) { + self.recorded + .try_write() + .unwrap() + .push(changed_profile.clone()); + (self.spy)(changed_profile) + } +} + +impl RustProfileChangeDriver { + pub fn recorded(&self) -> Vec { + self.recorded.try_read().unwrap().clone() + } + pub fn new() -> Arc { + Self::with_spy(|_| {}) + } + pub fn with_spy(spy: fn(Profile) -> ()) -> Arc { + Arc::new(Self { + spy, + recorded: RwLock::new(Vec::new()), + }) + } +} diff --git a/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs b/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs index 28b55b4ce..b47c7616f 100644 --- a/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs +++ b/src/system/drivers/secure_storage_driver/support/secure_storage_key.rs @@ -2,15 +2,11 @@ use crate::prelude::*; #[derive(Debug, Clone, PartialEq, Eq, Hash, uniffi::Enum)] pub enum SecureStorageKey { - SnapshotHeadersList, - ActiveProfileID, HostID, DeviceFactorSourceMnemonic { factor_source_id: FactorSourceIDFromHash, }, - ProfileSnapshot { - profile_id: ProfileID, - }, + ProfileSnapshot, } impl SecureStorageKey { @@ -19,15 +15,12 @@ impl SecureStorageKey { format!( "secure_storage_key_{}", match self { - SecureStorageKey::ActiveProfileID => - "activeProfileID".to_owned(), - SecureStorageKey::SnapshotHeadersList => "headers".to_owned(), SecureStorageKey::HostID => "host_id".to_owned(), SecureStorageKey::DeviceFactorSourceMnemonic { factor_source_id, } => format!("device_factor_source_{}", factor_source_id), - SecureStorageKey::ProfileSnapshot { profile_id } => - format!("profile_snapshot_{}", profile_id), + SecureStorageKey::ProfileSnapshot => + "profile_snapshot".to_owned(), } ) } @@ -44,14 +37,6 @@ mod tests { #[test] fn identifier() { - assert_eq!( - SecureStorageKey::ActiveProfileID.identifier(), - "secure_storage_key_activeProfileID" - ); - assert_eq!( - SecureStorageKey::SnapshotHeadersList.identifier(), - "secure_storage_key_headers" - ); assert_eq!( SecureStorageKey::DeviceFactorSourceMnemonic { factor_source_id: FactorSourceIDFromHash::sample() @@ -60,11 +45,8 @@ mod tests { "secure_storage_key_device_factor_source_device:f1a93d324dd0f2bff89963ab81ed6e0c2ee7e18c0827dc1d3576b2d9f26bbd0a" ); assert_eq!( - SecureStorageKey::ProfileSnapshot { - profile_id: ProfileID::sample() - } - .identifier(), - "secure_storage_key_profile_snapshot_ffffffff-ffff-ffff-ffff-ffffffffffff" + SecureStorageKey::ProfileSnapshot.identifier(), + "secure_storage_key_profile_snapshot" ); } } @@ -75,9 +57,7 @@ mod uniffi_tests { #[test] fn identifier() { - let key = SecureStorageKey::ProfileSnapshot { - profile_id: ProfileID::sample(), - }; + let key = SecureStorageKey::ProfileSnapshot; assert_eq!( key.clone().identifier(), secure_storage_key_identifier(&key) diff --git a/src/system/drivers/unsafe_storage_driver/support/test/ephemeral_unsafe_storage.rs b/src/system/drivers/unsafe_storage_driver/support/test/ephemeral_unsafe_storage.rs index 05f7714de..177d9f1f8 100644 --- a/src/system/drivers/unsafe_storage_driver/support/test/ephemeral_unsafe_storage.rs +++ b/src/system/drivers/unsafe_storage_driver/support/test/ephemeral_unsafe_storage.rs @@ -17,15 +17,19 @@ impl EphemeralUnsafeStorage { } } +#[async_trait::async_trait] impl UnsafeStorageDriver for EphemeralUnsafeStorage { - fn load_data(&self, key: UnsafeStorageKey) -> Result> { + async fn load_data( + &self, + key: UnsafeStorageKey, + ) -> Result> { self.storage .try_read() .map_err(|_| CommonError::UnsafeStorageReadError) .map(|s| s.get(&key).cloned()) } - fn save_data( + async fn save_data( &self, key: UnsafeStorageKey, value: BagOfBytes, @@ -39,7 +43,7 @@ impl UnsafeStorageDriver for EphemeralUnsafeStorage { Ok(()) } - fn delete_data_for_key(&self, key: UnsafeStorageKey) -> Result<()> { + async fn delete_data_for_key(&self, key: UnsafeStorageKey) -> Result<()> { let mut storage = self .storage .try_write() diff --git a/src/system/drivers/unsafe_storage_driver/support/test/fail_unsafe_storage.rs b/src/system/drivers/unsafe_storage_driver/support/test/fail_unsafe_storage.rs index 48abdf731..3ec74b534 100644 --- a/src/system/drivers/unsafe_storage_driver/support/test/fail_unsafe_storage.rs +++ b/src/system/drivers/unsafe_storage_driver/support/test/fail_unsafe_storage.rs @@ -5,12 +5,16 @@ use crate::prelude::*; #[derive(Debug)] pub(crate) struct AlwaysFailUnsafeStorage {} +#[async_trait::async_trait] impl UnsafeStorageDriver for AlwaysFailUnsafeStorage { - fn load_data(&self, _key: UnsafeStorageKey) -> Result> { + async fn load_data( + &self, + _key: UnsafeStorageKey, + ) -> Result> { panic!("AlwaysFailStorage does not implement `load_data"); } - fn save_data( + async fn save_data( &self, _key: UnsafeStorageKey, _data: BagOfBytes, @@ -18,7 +22,7 @@ impl UnsafeStorageDriver for AlwaysFailUnsafeStorage { Err(CommonError::Unknown) } - fn delete_data_for_key(&self, _key: UnsafeStorageKey) -> Result<()> { + async fn delete_data_for_key(&self, _key: UnsafeStorageKey) -> Result<()> { panic!("AlwaysFailStorage does not implement `delete_data_for_key"); } } diff --git a/src/system/drivers/unsafe_storage_driver/unsafe_storage_driver.rs b/src/system/drivers/unsafe_storage_driver/unsafe_storage_driver.rs index a3b51d9a1..c3881f719 100644 --- a/src/system/drivers/unsafe_storage_driver/unsafe_storage_driver.rs +++ b/src/system/drivers/unsafe_storage_driver/unsafe_storage_driver.rs @@ -1,10 +1,18 @@ use crate::prelude::*; #[uniffi::export(with_foreign)] +#[async_trait::async_trait] pub trait UnsafeStorageDriver: Send + Sync + std::fmt::Debug { - fn load_data(&self, key: UnsafeStorageKey) -> Result>; + async fn load_data( + &self, + key: UnsafeStorageKey, + ) -> Result>; - fn save_data(&self, key: UnsafeStorageKey, data: BagOfBytes) -> Result<()>; + async fn save_data( + &self, + key: UnsafeStorageKey, + data: BagOfBytes, + ) -> Result<()>; - fn delete_data_for_key(&self, key: UnsafeStorageKey) -> Result<()>; + async fn delete_data_for_key(&self, key: UnsafeStorageKey) -> Result<()>; } diff --git a/src/system/sargon_os/sargon_os.rs b/src/system/sargon_os/sargon_os.rs index 563187e8d..d1e0705cd 100644 --- a/src/system/sargon_os/sargon_os.rs +++ b/src/system/sargon_os/sargon_os.rs @@ -48,17 +48,7 @@ impl SargonOS { let secure_storage = &clients.secure_storage; - if let Some(loaded) = secure_storage.load_active_profile().await? { - info!("Loaded saved profile {}", &loaded.header); - let is_owner = Self::check_is_allowed_to_update_provided_profile( - &clients, &loaded, false, - ) - .await?; - - if !is_owner { - warn!("Loaded saved profile was last used on another device, will continue booting OS, but will unable to update Profile."); - } - + if let Some(loaded) = secure_storage.load_profile().await? { Ok(Arc::new(Self { clients, profile_holder: ProfileHolder::new(loaded), @@ -71,9 +61,7 @@ impl SargonOS { secure_storage.save_private_hd_factor_source(&bdfs).await?; - secure_storage - .save_profile_and_active_profile_id(&profile) - .await?; + secure_storage.save_profile(&profile).await?; info!("Saved new Profile and BDFS, finish booting SargonOS"); @@ -233,13 +221,13 @@ mod tests { let os = SUT::fast_boot().await; // ASSERT - let active_profile_id = os - .with_timeout(|x| x.secure_storage.load_active_profile_id()) + let active_profile = os + .with_timeout(|x| x.secure_storage.load_profile()) .await .unwrap() .unwrap(); - assert_eq!(active_profile_id, os.profile().id()); + assert_eq!(active_profile.id(), os.profile().id()); } #[actix_rt::test] @@ -250,10 +238,6 @@ mod tests { let secure_storage_client = SecureStorageClient::new(secure_storage_driver.clone()); secure_storage_client.save_profile(&profile).await.unwrap(); - secure_storage_client - .save_active_profile_id(profile.id()) - .await - .unwrap(); let drivers = Drivers::with_secure_storage(secure_storage_driver); let bios = Bios::new(drivers); @@ -268,215 +252,6 @@ mod tests { assert_eq!(active_profile.id(), profile.id()); } - #[actix_rt::test] - async fn test_boot_with_existing_unowned_profile_cannot_be_mutated() { - // ARRANGE (and ACT) - let secure_storage_driver = EphemeralSecureStorage::new(); - let profile = Profile::sample(); - let secure_storage_client = - SecureStorageClient::new(secure_storage_driver.clone()); - secure_storage_client.save_profile(&profile).await.unwrap(); - secure_storage_client - .save_active_profile_id(profile.id()) - .await - .unwrap(); - let drivers = Drivers::with_secure_storage(secure_storage_driver); - let bios = Bios::new(drivers); - - let os = timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, SUT::boot(bios)) - .await - .unwrap() - .unwrap(); - - let host_id = os.with_timeout(|x| x.host_id()).await.unwrap(); - - // ACT - let add_res = - os.with_timeout(|x| x.add_account(Account::sample())).await; - - // ASSERT - assert_eq!( - add_res, - Err(CommonError::ProfileUsedOnOtherDevice { - other_device_id: profile.header.last_used_on_device.id, - this_device_id: host_id.id - }) - ); - } - - #[actix_rt::test] - async fn test_boot_with_existing_unowned_profile_is_not_mutated_if_tried_to( - ) { - // ARRANGE (and ACT) - let secure_storage_driver = EphemeralSecureStorage::new(); - let profile = Profile::new( - Mnemonic::sample(), - HostId::sample(), - HostInfo::sample(), - ); - let secure_storage_client = - SecureStorageClient::new(secure_storage_driver.clone()); - secure_storage_client.save_profile(&profile).await.unwrap(); - secure_storage_client - .save_active_profile_id(profile.id()) - .await - .unwrap(); - let drivers = Drivers::with_secure_storage(secure_storage_driver); - let bios = Bios::new(drivers); - - let os = timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, SUT::boot(bios)) - .await - .unwrap() - .unwrap(); - - let new_account = Account::sample_stokenet(); - // ACT - let _ = os - .with_timeout(|x| x.add_account(new_account.clone())) - .await; - - // ASSERT - assert_eq!(os.profile(), profile.clone()); // not changed in memory - - let loaded_profile = os - .with_timeout(|x| x.secure_storage.load_active_profile()) - .await - .unwrap() - .unwrap(); - assert_eq!(loaded_profile, profile); // not changed in secure storage - } - - #[actix_rt::test] - async fn test_boot_with_existing_unowned_profile_when_claimed_can_be_changed( - ) { - // ARRANGE (and ACT) - let secure_storage_driver = EphemeralSecureStorage::new(); - let profile = Profile::new( - Mnemonic::sample(), - HostId::sample(), - HostInfo::sample(), - ); - let secure_storage_client = - SecureStorageClient::new(secure_storage_driver.clone()); - secure_storage_client.save_profile(&profile).await.unwrap(); - secure_storage_client - .save_active_profile_id(profile.id()) - .await - .unwrap(); - let drivers = Drivers::with_secure_storage(secure_storage_driver); - let bios = Bios::new(drivers); - - let os = timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, SUT::boot(bios)) - .await - .unwrap() - .unwrap(); - - let new_account = Account::sample_stokenet(); - // ACT - let claim_was_needed = - os.with_timeout(|x| x.claim_active_profile()).await.unwrap(); - let _ = os - .with_timeout(|x| x.add_account(new_account.clone())) - .await; - - // ASSERT - assert!(claim_was_needed); - assert_ne!(os.profile(), profile.clone()); // was changed in memory - assert_eq!( - os.profile() - .networks - .get_id(NetworkID::Stokenet) - .unwrap() - .accounts[0], - new_account.clone() - ); - - let loaded_profile = os - .with_timeout(|x| x.secure_storage.load_active_profile()) - .await - .unwrap() - .unwrap(); - assert_ne!(loaded_profile.clone(), profile); // was changed in secure storage - - assert_eq!( - loaded_profile - .networks - .get_id(NetworkID::Stokenet) - .unwrap() - .accounts[0], - new_account.clone() - ); - } - - #[actix_rt::test] - async fn test_boot_not_owned_emits_event() { - // ARRANGE (and ACT) - let secure_storage_driver = EphemeralSecureStorage::new(); - let event_bus_driver = RustEventBusDriver::new(); - let profile = Profile::sample(); - let secure_storage_client = - SecureStorageClient::new(secure_storage_driver.clone()); - secure_storage_client.save_profile(&profile).await.unwrap(); - secure_storage_client - .save_active_profile_id(profile.id()) - .await - .unwrap(); - let drivers = Drivers::new( - RustNetworkingDriver::new(), - secure_storage_driver.clone(), - RustEntropyDriver::new(), - RustHostInfoDriver::new(), - RustLoggingDriver::new(), - event_bus_driver.clone(), - RustFileSystemDriver::new(), - EphemeralUnsafeStorage::new(), - ); - let bios = Bios::new(drivers); - - // ACT - let _ = timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, SUT::boot(bios)) - .await - .unwrap() - .unwrap(); - - // ASSERT - assert!(event_bus_driver - .recorded() - .iter() - .any(|e| e.event.kind() == EventKind::ProfileUsedOnOtherDevice)); - } - - #[actix_rt::test] - async fn test_boot_with_existing_profile_active_profile_id() { - // ARRANGE (and ACT) - let secure_storage_driver = EphemeralSecureStorage::new(); - let profile = Profile::sample(); - let secure_storage_client = - SecureStorageClient::new(secure_storage_driver.clone()); - secure_storage_client.save_profile(&profile).await.unwrap(); - secure_storage_client - .save_active_profile_id(profile.id()) - .await - .unwrap(); - let drivers = Drivers::with_secure_storage(secure_storage_driver); - let bios = Bios::new(drivers); - - // ACT - let os = timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, SUT::boot(bios)) - .await - .unwrap() - .unwrap(); - - // ASSERT - let active_profile_id = os - .with_timeout(|x| x.secure_storage.load_active_profile_id()) - .await - .unwrap() - .unwrap(); - - assert_eq!(active_profile_id, profile.id()); - } - #[actix_rt::test] async fn test_change_log_level() { // ARRANGE (and ACT) diff --git a/src/system/sargon_os/sargon_os_accounts.rs b/src/system/sargon_os/sargon_os_accounts.rs index eddf3eeed..c5d49b3e2 100644 --- a/src/system/sargon_os/sargon_os_accounts.rs +++ b/src/system/sargon_os/sargon_os_accounts.rs @@ -757,7 +757,7 @@ mod tests { // ASSERT let saved_profile = os - .with_timeout(|x| x.secure_storage.load_active_profile()) + .with_timeout(|x| x.secure_storage.load_profile()) .await .unwrap() .unwrap(); diff --git a/src/system/sargon_os/sargon_os_profile.rs b/src/system/sargon_os/sargon_os_profile.rs index ac560fe56..aa947a8ad 100644 --- a/src/system/sargon_os/sargon_os_profile.rs +++ b/src/system/sargon_os/sargon_os_profile.rs @@ -38,6 +38,24 @@ impl SargonOS { #[uniffi::export] impl SargonOS { + pub async fn set_profile(&self, profile: Profile) -> Result<()> { + if profile.id() != self.profile().id() { + return Err( + CommonError::TriedToUpdateProfileWithOneWithDifferentID, + ); + } + + self.update_profile_with(|mut p| { + *p = profile.clone(); + Ok(()) + }) + .await?; + + self.clients.profile_change.emit(profile).await; + + Ok(()) + } + /// Checks if current Profile contains any `ProfileNetwork`s. pub fn has_any_network(&self) -> bool { self.profile_holder @@ -50,37 +68,6 @@ impl SargonOS { .access_profile_with(|p| p.has_any_account_on_any_network()) } - /// Returns the current profile in full. This is a COSTLY operation - /// and hosts SHOULD NOT do it lightheartedly, prefer using more specific - /// reading operations such as `os.current_network_id` or `os.accounts_for_display_on_current_network` etc, which are cheap operations compared - /// to using this. - /// - /// In the future will will most likely deprecate this method. - pub fn profile(&self) -> Profile { - self.profile_holder.profile() - } - - #[allow(non_snake_case)] - #[deprecated( - since = "0.0.1", - note = "Hosts SHOULD migrate to use more specialized methods on SargonOS instead, e.g. `createAndSaveNewAccount`. And SargonOS should be the SOLE object to perform the mutation and persisting." - )] - pub async fn DEPRECATED_save_ffi_changed_profile( - &self, - profile: Profile, - ) -> Result<()> { - if profile.id() != self.profile().id() { - return Err( - CommonError::TriedToUpdateProfileWithOneWithDifferentID, - ); - } - self.update_profile_with(|mut p| { - *p = profile.clone(); - Ok(()) - }) - .await - } - /// Imports the `profile`, claims it, set it as active (current) one and /// saves it into secure storage (with the claim modification). /// @@ -92,9 +79,7 @@ impl SargonOS { let mut profile = profile; self.claim_profile(&mut profile).await?; - self.secure_storage - .save_profile_and_active_profile_id(&profile) - .await?; + self.secure_storage.save_profile(&profile).await?; debug!( "Saved imported profile into secure storage, id: {}", @@ -118,27 +103,6 @@ impl SargonOS { Ok(()) } - /// Claims the active profile, meaning the `last_used_on_device` in `header` - /// is updated. - /// - /// Returns `true` if the profile was changed (i.e. if claim was indeed needed), - /// `false`` otherwise. - pub async fn claim_active_profile(&self) -> Result { - let host_id = self.host_id().await?; - let host_info = self.host_info().await; - - self.maybe_validate_ownership_update_profile_with( - false, // we do NOT validate ownership, since this method is claiming - |mut p| { - Ok(Self::claim_provided_profile( - &mut p, - DeviceInfo::new_from_info(&host_id, &host_info), - )) - }, - ) - .await - } - /// Deletes the profile and the active profile id and all references Device /// factor sources from secure storage, and creates a new empty profile /// and a new bdfs, and saves those into secure storage, returns the ID of @@ -154,13 +118,21 @@ impl SargonOS { .save_private_hd_factor_source(&bdfs) .await?; - self.secure_storage - .save_profile_and_active_profile_id(&profile) - .await?; + self.secure_storage.save_profile(&profile).await?; Ok(profile_id) } + /// Returns the current profile in full. This is a COSTLY operation + /// and hosts SHOULD NOT do it lightheartedly, prefer using more specific + /// reading operations such as `os.current_network_id` or `os.accounts_for_display_on_current_network` etc, which are cheap operations compared + /// to using this. + /// + /// In the future will will most likely deprecate this method. + pub fn profile(&self) -> Profile { + self.profile_holder.profile() + } + /// Do NOT use in production. Instead use `delete_profile_then_create_new_with_bdfs` /// in production. This method does not persist the new profile. pub async fn emulate_fresh_install(&self) -> Result<()> { @@ -173,92 +145,6 @@ impl SargonOS { } impl SargonOS { - /// Returns `Err`` if the **active** profile is not 'owned by host', - /// meaning `profile.header.last_used_on_device.id != device_info.id`. - /// - /// # Emits Event - /// Emits `Event::ProfileUsedOnOtherDevice` if `profile` is not 'owned by - /// host'. - pub(crate) async fn validate_is_allowed_to_mutate_active_profile( - &self, - ) -> Result<()> { - Self::validate_is_allowed_to_update_provided_profile( - &self.clients, - &self.profile(), - ) - .await - } - - /// Returns `Err` if the **provided** `profile` is not 'owned by host', - /// meaning `profile.header.last_used_on_device.id != device_info.id`. - /// - /// # Emits Event - /// Emits `Event::ProfileUsedOnOtherDevice` if `profile` is not 'owned by - /// host'. - pub(crate) async fn validate_is_allowed_to_update_provided_profile( - clients: &Clients, - profile: &Profile, - ) -> Result<()> { - Self::check_is_allowed_to_update_provided_profile( - clients, profile, true, - ) - .await?; - Ok(()) - } - - /// Checks if the **provided** `profile` is not 'owned by host', - /// meaning `profile.header.last_used_on_device.id != device_info.id`, - /// and if `err_on_lack_of_ownership` an Err is returns, otherwise `Ok(false)` - /// is returned. - /// - /// # Emits Event - /// Emits `Event::ProfileUsedOnOtherDevice` if `profile` is not 'owned by - /// host'. - pub(crate) async fn check_is_allowed_to_update_provided_profile( - clients: &Clients, - profile: &Profile, - err_on_lack_of_ownership: bool, - ) -> Result { - debug!("Checking if profile.header.last_used_on_device is self.device_info"); - let host_id = Self::get_host_id(clients).await?; - let last_used = profile.header.last_used_on_device.clone(); - if last_used.id == host_id.id { - debug!("Ownership check passed (profile.header.last_used_on_device == self.device_info)"); - Ok(true) - } else { - warn!("Profile was last used on another device, will not be able to update it until it has been claimed."); - clients - .event_bus - .emit(EventNotification::profile_used_on_other_device( - last_used.clone(), - )) - .await; - if err_on_lack_of_ownership { - Err(CommonError::ProfileUsedOnOtherDevice { - other_device_id: last_used.id, - this_device_id: host_id.id, - }) - } else { - // used by SargonOS::boot - Ok(false) - } - } - } - - /// Validates ownership of Profile, then updates and **saves** it to - /// secure storage, after mutating it with `mutate`. - /// - /// # Emits - /// Emits `Event::ProfileSaved` after having successfully written the JSON - /// of the active profile to secure storage. - pub(crate) async fn update_profile_with(&self, mutate: F) -> Result - where - F: Fn(RwLockWriteGuard<'_, Profile>) -> Result, - { - self.maybe_validate_ownership_update_profile_with(true, mutate) - .await - } - /// Updates and **saves** profile to secure storage, after /// mutating it with `mutate`, optionally validating ownership of Profile /// first. @@ -269,17 +155,10 @@ impl SargonOS { /// # Emits /// Emits `Event::ProfileSaved` after having successfully written the JSON /// of the active profile to secure storage. - pub(crate) async fn maybe_validate_ownership_update_profile_with( - &self, - validate_ownership: bool, // should only ever pass `false` from `claim` - mutate: F, - ) -> Result + pub(crate) async fn update_profile_with(&self, mutate: F) -> Result where F: Fn(RwLockWriteGuard<'_, Profile>) -> Result, { - if validate_ownership { - self.validate_is_allowed_to_mutate_active_profile().await?; - } let res = self.profile_holder.update_profile_with(mutate)?; self.profile_holder.update_profile_with(|mut p| { p.update_header(None); @@ -306,17 +185,10 @@ impl SargonOS { /// Emits `Event::ProfileSaved` after having successfully written the JSON /// of the active profile to secure storage. pub(crate) async fn save_profile(&self, profile: &Profile) -> Result<()> { - self.validate_is_allowed_to_mutate_active_profile().await?; - let secure_storage = &self.secure_storage; secure_storage - .save( - SecureStorageKey::ProfileSnapshot { - profile_id: profile.header.id, - }, - profile, - ) + .save(SecureStorageKey::ProfileSnapshot, profile) .await?; self.event_bus @@ -340,7 +212,6 @@ impl SargonOS { } secure_storage.delete_profile(self.profile().id()).await?; - secure_storage.delete_active_profile_id().await?; Ok(()) } @@ -467,7 +338,7 @@ mod tests { // ASSERT let saved = os - .with_timeout(|x| x.secure_storage.load_active_profile()) + .with_timeout(|x| x.secure_storage.load_profile()) .await .unwrap() .unwrap(); @@ -536,7 +407,7 @@ mod tests { .contains_id(new_account.id())); let loaded = os - .with_timeout(|x| x.secure_storage.load_active_profile()) + .with_timeout(|x| x.secure_storage.load_profile()) .await .unwrap() .unwrap(); @@ -548,26 +419,6 @@ mod tests { .contains_id(new_account.id())); } - #[actix_rt::test] - async fn test_import_profile_active_profile_id_is_set() { - // ARRANGE - let os = SUT::fast_boot().await; - - // ACT - os.with_timeout(|x| x.import_profile(Profile::sample())) - .await - .unwrap(); - - // ASSERT - let active_profile_id = os - .with_timeout(|x| x.secure_storage.load_active_profile_id()) - .await - .unwrap() - .unwrap(); - - assert_eq!(active_profile_id, os.profile().id()); - } - #[actix_rt::test] async fn test_delete_profile_then_create_new_with_bdfs_old_bdfs_is_deleted() { @@ -592,24 +443,25 @@ mod tests { } #[actix_rt::test] - async fn test_delete_profile_then_create_new_with_bdfs_old_profile_is_deleted( + async fn test_delete_profile_then_create_new_with_bdfs_new_profile_replaces_old( ) { // ARRANGE let bdfs = MnemonicWithPassphrase::sample(); let os = SUT::fast_boot_bdfs(bdfs.clone()).await; - let profile_id = os.profile().id(); // ACT - os.with_timeout(|x| x.delete_profile_then_create_new_with_bdfs()) + let new_profile_id = os + .with_timeout(|x| x.delete_profile_then_create_new_with_bdfs()) .await .unwrap(); // ASSERT - let load_old_profile_result = os - .with_timeout(|x| x.secure_storage.load_profile_with_id(profile_id)) - .await; + let load_current_profile = os + .with_timeout(|x| x.secure_storage.load_profile()) + .await + .unwrap(); - assert!(load_old_profile_result.is_err()); + assert_eq!(load_current_profile.unwrap().id(), new_profile_id) } #[actix_rt::test] @@ -649,7 +501,7 @@ mod tests { // ASSERT let active_profile = os - .with_timeout(|x| x.secure_storage.load_active_profile()) + .with_timeout(|x| x.secure_storage.load_profile()) .await .unwrap() .unwrap(); @@ -698,19 +550,15 @@ mod tests { let second = os.profile().id(); assert_ne!(second, first); let load_profile_res = os - .with_timeout(|x| x.secure_storage.load_profile_with_id(second)) - .await; + .with_timeout(|x| x.secure_storage.load_profile()) + .await + .unwrap(); - assert_eq!( - load_profile_res, - Err(CommonError::UnableToLoadProfileFromSecureStorage { - profile_id: second - }) - ); + assert!(load_profile_res.is_none()); } #[actix_rt::test] - async fn test_deprecated_save_ffi_changed_profile() { + async fn test_set_profile() { // ARRANGE let os = SUT::fast_boot().await; @@ -725,28 +573,21 @@ mod tests { profile.networks.append(new_network.clone()); // ACT - os.with_timeout(|x| { - x.DEPRECATED_save_ffi_changed_profile(profile.clone()) - }) - .await - .unwrap(); + os.with_timeout(|x| x.set_profile(profile.clone())) + .await + .unwrap(); // ASSERT assert_eq!(os.profile().networks, profile.networks); // header has been updated so cannot do full profile comparison. } #[actix_rt::test] - async fn test_deprecated_save_ffi_changed_profile_is_err_when_different_profile_id( - ) { + async fn test_set_profile_is_err_when_different_profile_id() { // ARRANGE let os = SUT::fast_boot().await; // ACT - let res = os - .with_timeout(|x| { - x.DEPRECATED_save_ffi_changed_profile(Profile::sample()) - }) - .await; + let res = os.with_timeout(|x| x.set_profile(Profile::sample())).await; // ASSERT assert_eq!(