Implement Rust-owned Android local and HTTPS onboarding (#95)
This commit is contained in:
1 parent
66b1d4a4c4
commit
36e5f55283
46 files changed
+3002
-79
No files matched your search
+6
-5
@@ -1,10 +1,10 @@
|
||||
# Native Android build
|
||||
|
||||
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
||||
[parity matrix](../docs/android/parity.md). The native foundation implements #89–#94, including private storage, Keystore
|
||||
[parity matrix](../docs/android/parity.md). The native foundation and onboarding implement #89–#95, including private storage, Keystore
|
||||
callbacks and build gates. Both variants invoke Rust through the generated
|
||||
UniFFI API. Onboarding and the feature screens remain owned by their milestone
|
||||
issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
||||
UniFFI API. Local-first and HTTPS onboarding is described in
|
||||
[onboarding](../docs/android/onboarding.md); feature screens remain owned by their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
||||
ARM-only runner/toolchain policy and artifact audits.
|
||||
|
||||
## Reproducible setup
|
||||
@@ -54,8 +54,9 @@ code cannot conceal a domain implementation.
|
||||
|
||||
Debug controls invoke the storage-owned MobileShell::fixture through the native
|
||||
API for Loading/Empty/Ready/Locked/Error. A build-generated fixture inventory is
|
||||
used only as the test oracle. Release calls the same Rust Empty fixture without
|
||||
debug controls; repository initialization is owned by #90. Feature code consumes
|
||||
used only as the test oracle. Release loads the real Rust private-storage state without debug controls.
|
||||
An unconfigured runtime opens staged onboarding; Preferences can add HTTPS
|
||||
synchronization or replace its protected token. Feature code consumes
|
||||
only the facade, including typed native recovery states.
|
||||
|
||||
## Identity, signing and update
|
||||
|
||||
@@ -311,4 +311,5 @@ org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
|
||||
org.ow2.asm:asm:9.9=androidLintTool
|
||||
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
|
||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
||||
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebug,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,hiltCompileOnlyRelease,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.SetupKey
|
||||
import de.rfc1437.ironstorage.core.SetupStatus
|
||||
import de.rfc1437.ironstorage.features.OnboardingScreen
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class OnboardingScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun localAndRemoteFormsUseTypedFacadeAndClearSubmittedSecrets() {
|
||||
val repository = FixtureRepository()
|
||||
val secrets = SetupFormViewModel()
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setContent { MaterialTheme { OnboardingScreen(repository, secrets) } }
|
||||
}
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("Create local store").performClick()
|
||||
compose.onNodeWithText("Generate GPG key").performScrollTo().performClick()
|
||||
compose.onNodeWithText(
|
||||
"Key name and email"
|
||||
).performTextInput("Fixture <fixture@example.test>")
|
||||
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
|
||||
compose.onNodeWithText("Generate GPG key").performScrollTo().performClick()
|
||||
compose.onNodeWithText("Create encrypted local store").performScrollTo().performClick()
|
||||
compose.onNodeWithText("Add HTTPS synchronization").assertIsDisplayed().performClick()
|
||||
compose.onNodeWithText("HTTPS server URL").performTextInput("https://example.test")
|
||||
compose.onNodeWithText("Account").performTextInput("alice")
|
||||
compose.onNodeWithText(
|
||||
"Repository path (owner/repository)"
|
||||
).performTextInput("team/passwords")
|
||||
compose.onNodeWithText(
|
||||
"Application token"
|
||||
).performScrollTo().performTextInput("public-test-token")
|
||||
compose.onNodeWithText("Connect local store").performScrollTo().performClick()
|
||||
assertEquals(listOf("generate", "local", "connect"), repository.calls)
|
||||
assertTrue(
|
||||
secrets.passphrase.isEmpty() && secrets.token.isEmpty() && secrets.armor.isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private class FixtureRepository : OnboardingRepository {
|
||||
override val setup = MutableStateFlow(SetupStatus())
|
||||
val calls = mutableListOf<String>()
|
||||
override fun refresh() {
|
||||
setup.value = setup.value.copy(branches = emptyList())
|
||||
}
|
||||
override fun generate(userId: String, passphrase: String) {
|
||||
assertEquals("Fixture <fixture@example.test>", userId)
|
||||
assertEquals("public-test-passphrase", passphrase)
|
||||
calls.add("generate")
|
||||
setup.value =
|
||||
setup.value.copy(
|
||||
keys = listOf(SetupKey("fixture-key", listOf(userId))),
|
||||
selectedKey = "fixture-key"
|
||||
)
|
||||
}
|
||||
override fun createLocal(fingerprint: String, passphrase: String) {
|
||||
assertEquals("fixture-key", fingerprint)
|
||||
assertEquals("public-test-passphrase", passphrase)
|
||||
calls.add("local")
|
||||
setup.value = setup.value.copy(configured = true)
|
||||
}
|
||||
override fun connect(server: String, account: String, repository: String, token: String) {
|
||||
assertEquals("https://example.test", server)
|
||||
assertEquals("alice", account)
|
||||
assertEquals("team/passwords", repository)
|
||||
assertEquals("public-test-token", token)
|
||||
calls.add("connect")
|
||||
setup.value = setup.value.copy(connected = true)
|
||||
}
|
||||
override fun importKey(armor: String, passphrase: String) = error("Unexpected import")
|
||||
override fun discover(server: String, account: String, repository: String, token: String) =
|
||||
error("Unexpected discovery")
|
||||
override fun cloneStore(
|
||||
branch: String,
|
||||
fingerprint: String,
|
||||
passphrase: String,
|
||||
useExisting: Boolean
|
||||
) = error("Unexpected clone")
|
||||
override fun replaceToken(account: String, token: String) =
|
||||
error("Unexpected token replacement")
|
||||
override fun cancel() = Unit
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,16 @@ import androidx.compose.ui.Modifier
|
||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellState
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.features.ShellScreen
|
||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||
|
||||
@Composable
|
||||
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
|
||||
fun VariantContent(
|
||||
viewModel: ShellViewModel,
|
||||
repository: ShellRepository,
|
||||
setupForm: SetupFormViewModel
|
||||
) {
|
||||
Column(Modifier.statusBarsPadding()) {
|
||||
Row(Modifier.horizontalScroll(rememberScrollState())) {
|
||||
ShellState.entries.forEach { state ->
|
||||
@@ -27,6 +32,6 @@ fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ShellScreen(viewModel)
|
||||
ShellScreen(viewModel, setupForm)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<application
|
||||
android:name=".IronStorageApplication"
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||
import javax.inject.Inject
|
||||
@@ -18,6 +19,8 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject lateinit var repository: ShellRepository
|
||||
|
||||
private val setupForm: SetupFormViewModel by viewModels()
|
||||
|
||||
private val viewModel: ShellViewModel by viewModels()
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -31,7 +34,7 @@ class MainActivity : FragmentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
VariantContent(viewModel, repository)
|
||||
VariantContent(viewModel, repository, setupForm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.NativePrivateSession
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||
import de.rfc1437.ironstorage.platform.privateDirectories
|
||||
@@ -18,6 +21,25 @@ import kotlinx.coroutines.SupervisorJob
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ShellModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun privateSession(
|
||||
@ApplicationContext context: Context,
|
||||
keystore: AndroidKeystoreAdapter
|
||||
): NativePrivateSession =
|
||||
NativePrivateSession(context, { privateDirectories(context) }, keystore)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun onboardingRepository(
|
||||
session: NativePrivateSession,
|
||||
shell: ShellRepository
|
||||
): OnboardingRepository = NativeOnboardingRepository(
|
||||
session,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
shell
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter =
|
||||
@@ -25,13 +47,9 @@ object ShellModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun shellRepository(
|
||||
@ApplicationContext context: Context,
|
||||
keystore: AndroidKeystoreAdapter
|
||||
): ShellRepository = NativeFixtureShellRepository(
|
||||
context,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
{ privateDirectories(context) },
|
||||
keystore
|
||||
)
|
||||
fun shellRepository(session: NativePrivateSession): ShellRepository =
|
||||
NativeFixtureShellRepository(
|
||||
session,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,16 @@ package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.features.ShellScreen
|
||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||
|
||||
@Composable
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
|
||||
ShellScreen(viewModel)
|
||||
fun VariantContent(
|
||||
viewModel: ShellViewModel,
|
||||
repository: ShellRepository,
|
||||
setupForm: SetupFormViewModel
|
||||
) {
|
||||
ShellScreen(viewModel, setupForm)
|
||||
}
|
||||
@@ -154,4 +154,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||
org.ow2.asm:asm:9.9=androidLintTool
|
||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
||||
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
|
||||
+22
-33
@@ -1,12 +1,8 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.content.Context
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.NativeFailureKind
|
||||
import de.rfc1437.ironstorage.bridge.NativeRuntime
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileIcon
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageErrorKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
|
||||
@@ -25,22 +21,21 @@ import kotlinx.coroutines.withContext
|
||||
/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
|
||||
class NativeFixtureShellRepository internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val loadCurrent: (suspend () -> ShellSnapshot)? = null,
|
||||
private val load: suspend (ShellState) -> ShellSnapshot
|
||||
) : ShellRepository {
|
||||
constructor(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
directories: () -> PrivateDirectories,
|
||||
keystore: AndroidKeystore
|
||||
) :
|
||||
this(scope, nativeLoader(context, directories, keystore))
|
||||
constructor(session: NativePrivateSession, scope: CoroutineScope) : this(
|
||||
scope,
|
||||
{ nativeLoader(session, true)(ShellState.Empty) },
|
||||
nativeLoader(session)
|
||||
)
|
||||
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
|
||||
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
|
||||
private val revision = AtomicLong()
|
||||
private var job: Job? = null
|
||||
|
||||
init {
|
||||
showFixture(ShellState.Empty)
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun selectTab(tab: ShellTab) {
|
||||
@@ -48,17 +43,26 @@ class NativeFixtureShellRepository internal constructor(
|
||||
}
|
||||
|
||||
fun showFixture(state: ShellState) {
|
||||
update(true) { load(state) }
|
||||
}
|
||||
|
||||
override fun refresh() {
|
||||
update(false, loadCurrent ?: { load(ShellState.Empty) })
|
||||
}
|
||||
|
||||
private fun update(fixture: Boolean, loader: suspend () -> ShellSnapshot) {
|
||||
val operation = revision.incrementAndGet()
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
val result = try {
|
||||
load(state)
|
||||
loader()
|
||||
} catch (error: NativeBridgeException) {
|
||||
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
|
||||
}
|
||||
if (revision.get() == operation) {
|
||||
mutableSnapshot.update {
|
||||
result.copy(
|
||||
fixture = fixture,
|
||||
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
|
||||
)
|
||||
}
|
||||
@@ -68,31 +72,16 @@ class NativeFixtureShellRepository internal constructor(
|
||||
}
|
||||
|
||||
private fun nativeLoader(
|
||||
context: Context,
|
||||
directoryProvider: () -> PrivateDirectories,
|
||||
keystore: AndroidKeystore
|
||||
session: NativePrivateSession,
|
||||
current: Boolean = false
|
||||
): suspend (ShellState) -> ShellSnapshot {
|
||||
val runtime = NativeRuntime(context)
|
||||
var storage: MobilePrivateRuntime? = null
|
||||
var firstLoad = true
|
||||
val runtime = session.runtime
|
||||
return { state ->
|
||||
withContext(Dispatchers.IO) {
|
||||
var recovery: StorageRecovery? = null
|
||||
val shell = if (firstLoad) {
|
||||
firstLoad = false
|
||||
val shell = if (current) {
|
||||
try {
|
||||
val directories = directoryProvider()
|
||||
val initialized = storage ?: runtime.privateStorage(
|
||||
MobilePrivateDirectories(
|
||||
directories.files,
|
||||
directories.noBackup,
|
||||
directories.cache
|
||||
)
|
||||
)
|
||||
storage = initialized
|
||||
initialized.activate()
|
||||
val secrets = initialized.androidSecretBackend(KeystoreCallback(keystore))
|
||||
initialized.verifySecureStorage(secrets)
|
||||
val initialized = session.storage()
|
||||
recovery = initialized.status().recovery?.toFacade()
|
||||
initialized.shell()
|
||||
} catch (error: MobilePrivateStorageFfiException.Failed) {
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidOnboarding
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidRemoteOnboarding
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAndroidSetupStatus
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NativeOnboardingRepository(
|
||||
private val session: NativePrivateSession,
|
||||
private val scope: CoroutineScope,
|
||||
private val shell: ShellRepository
|
||||
) : OnboardingRepository {
|
||||
private val mutableSetup = MutableStateFlow(SetupStatus())
|
||||
override val setup: StateFlow<SetupStatus> = mutableSetup
|
||||
|
||||
@Volatile private var operation: MobileAndroidOnboarding? = null
|
||||
private var remote: MobileAndroidRemoteOnboarding? = null
|
||||
|
||||
@Volatile private var pollProgress = false
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun refresh() = runSetup {
|
||||
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
|
||||
remote?.close()
|
||||
remote = null
|
||||
operation = session.storage().androidOnboarding(session.backend())
|
||||
updateStatus(requireNotNull(operation).status())
|
||||
shell.refresh()
|
||||
}
|
||||
|
||||
private fun newOperation(): MobileAndroidOnboarding {
|
||||
remote?.close()
|
||||
remote = null
|
||||
return session.storage().androidOnboarding(session.backend()).also { operation = it }
|
||||
}
|
||||
|
||||
override fun generate(userId: String, passphrase: String) = runSetup {
|
||||
updateStatus(newOperation().generate(userId, passphrase))
|
||||
}
|
||||
override fun importKey(armor: String, passphrase: String) = runSetup {
|
||||
updateStatus(newOperation().importKey(armor, passphrase.ifEmpty { null }))
|
||||
}
|
||||
override fun createLocal(fingerprint: String, passphrase: String) = runSetup {
|
||||
completed(newOperation().setupLocal(fingerprint, passphrase.ifEmpty { null }))
|
||||
}
|
||||
override fun discover(server: String, account: String, repository: String, token: String) =
|
||||
runSetup {
|
||||
val setup = newOperation()
|
||||
val request = setup.remote(server, account, repository, token).also { remote = it }
|
||||
val discovery = request.discover()
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
branches = discovery.branches,
|
||||
selectedBranch = discovery.branches.getOrNull(
|
||||
discovery.selectedBranch.toInt()
|
||||
).orEmpty(),
|
||||
title = "Select a branch",
|
||||
detail = "Choose the branch and private GPG key for this store."
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun cloneStore(
|
||||
branch: String,
|
||||
fingerprint: String,
|
||||
passphrase: String,
|
||||
useExisting: Boolean
|
||||
) = runSetup {
|
||||
completed(
|
||||
requireNotNull(remote).cloneStore(
|
||||
branch,
|
||||
fingerprint,
|
||||
passphrase.ifEmpty {
|
||||
null
|
||||
},
|
||||
useExisting
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun connect(server: String, account: String, repository: String, token: String) =
|
||||
runSetup {
|
||||
val setup = newOperation()
|
||||
setup.remote(server, account, repository, token).use { completed(it.connect()) }
|
||||
}
|
||||
override fun replaceToken(account: String, token: String) = runSetup {
|
||||
newOperation().replaceToken(account, token)
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Token updated",
|
||||
detail = "The previous token was replaced in protected device storage."
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun cancel() {
|
||||
if (!mutableSetup.value.busy) return
|
||||
scope.launch(Dispatchers.IO) { operation?.cancel() }
|
||||
}
|
||||
private fun completed(outcome: MobileOnboardingOutcome) {
|
||||
updateStatus(requireNotNull(operation).status())
|
||||
mutableSetup.update {
|
||||
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
|
||||
}
|
||||
remote?.close()
|
||||
remote = null
|
||||
shell.refresh()
|
||||
}
|
||||
private fun updateStatus(status: MobileAndroidSetupStatus) {
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = status.title,
|
||||
detail = status.detail,
|
||||
configured = status.configured,
|
||||
connected = status.connected,
|
||||
existingClone = status.existingClone,
|
||||
selectedKey = status.selectedKey.orEmpty(),
|
||||
keys = status.keys.map { key -> SetupKey(key.fingerprint, key.userIds) }
|
||||
)
|
||||
}
|
||||
}
|
||||
private fun runSetup(action: () -> Unit) {
|
||||
if (mutableSetup.value.busy) return
|
||||
pollProgress = true
|
||||
mutableSetup.update { it.copy(busy = true, failure = false) }
|
||||
scope.launch {
|
||||
val progress = launch {
|
||||
while (mutableSetup.value.busy) {
|
||||
delay(200)
|
||||
val value = withContext(Dispatchers.IO) { operation?.progress() }
|
||||
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
|
||||
mutableSetup.update {
|
||||
if (pollProgress) {
|
||||
it.copy(
|
||||
title = phase.title,
|
||||
detail = phase.detail
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
withContext(Dispatchers.IO) { action() }
|
||||
} catch (error: MobileOnboardingFfiException.Failed) {
|
||||
pollProgress = false
|
||||
if (error.kind == MobileOnboardingErrorKind.INTERRUPTED) {
|
||||
withContext(Dispatchers.IO) {
|
||||
remote?.close()
|
||||
remote = null
|
||||
}
|
||||
mutableSetup.update { it.copy(branches = emptyList()) }
|
||||
}
|
||||
mutableSetup.update {
|
||||
it.copy(title = error.title, detail = error.detail, failure = true)
|
||||
}
|
||||
} catch (_: MobilePrivateStorageFfiException.Failed) {
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Private storage unavailable",
|
||||
detail = "Unlock the device and retry. Existing data was retained.",
|
||||
failure = true
|
||||
)
|
||||
}
|
||||
} catch (_: NativeBridgeException) {
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Application update required",
|
||||
detail = "The native storage service could not be loaded.",
|
||||
failure = true
|
||||
)
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
pollProgress = false
|
||||
mutableSetup.update {
|
||||
it.copy(
|
||||
title = "Private storage unavailable",
|
||||
detail = "The app-private directories could not be opened.",
|
||||
failure = true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
pollProgress = false
|
||||
progress.cancel()
|
||||
mutableSetup.update { it.copy(busy = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.content.Context
|
||||
import de.rfc1437.ironstorage.bridge.NativeRuntime
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateRuntime
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
|
||||
|
||||
/** Both facades share one Rust owner and retain its Keystore callbacks for its lifetime. */
|
||||
class NativePrivateSession(
|
||||
context: Context,
|
||||
private val directories: () -> PrivateDirectories,
|
||||
private val keystore: AndroidKeystore
|
||||
) {
|
||||
val runtime = NativeRuntime(context)
|
||||
private var initialized: MobilePrivateRuntime? = null
|
||||
private var secrets: MobileSecretBackend? = null
|
||||
|
||||
@Synchronized
|
||||
fun storage(): MobilePrivateRuntime {
|
||||
initialized?.let { return it }
|
||||
val roots = directories()
|
||||
val storage = runtime.privateStorage(
|
||||
MobilePrivateDirectories(roots.files, roots.noBackup, roots.cache)
|
||||
)
|
||||
storage.activate()
|
||||
val backend = storage.androidSecretBackend(KeystoreCallback(keystore))
|
||||
storage.verifySecureStorage(backend)
|
||||
secrets = backend
|
||||
initialized = storage
|
||||
return storage
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun backend(): MobileSecretBackend {
|
||||
storage()
|
||||
return requireNotNull(secrets)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class SetupKey(val fingerprint: String, val userIds: List<String>)
|
||||
data class SetupStatus(
|
||||
val configured: Boolean = false,
|
||||
val connected: Boolean = false,
|
||||
val existingClone: Boolean = false,
|
||||
val keys: List<SetupKey> = emptyList(),
|
||||
val selectedKey: String = "",
|
||||
val branches: List<String> = emptyList(),
|
||||
val selectedBranch: String = "",
|
||||
val busy: Boolean = false,
|
||||
val title: String = "Set up your password store",
|
||||
val detail: String = "Create a local store or clone an existing HTTPS repository.",
|
||||
val failure: Boolean = false
|
||||
)
|
||||
|
||||
interface OnboardingRepository {
|
||||
val setup: StateFlow<SetupStatus>
|
||||
fun refresh()
|
||||
fun generate(userId: String, passphrase: String)
|
||||
fun importKey(armor: String, passphrase: String)
|
||||
fun createLocal(fingerprint: String, passphrase: String)
|
||||
fun discover(server: String, account: String, repository: String, token: String)
|
||||
fun cloneStore(branch: String, fingerprint: String, passphrase: String, useExisting: Boolean)
|
||||
fun connect(server: String, account: String, repository: String, token: String)
|
||||
fun replaceToken(account: String, token: String)
|
||||
fun cancel()
|
||||
}
|
||||
@@ -44,6 +44,7 @@ data class ShellSnapshot(
|
||||
val state: ShellState,
|
||||
val pages: List<ShellPage>,
|
||||
val selectedTab: ShellTab = ShellTab.Home,
|
||||
val fixture: Boolean = false,
|
||||
val nativeFailure: NativeFailure? = null,
|
||||
val storageRecovery: StorageRecovery? = null
|
||||
)
|
||||
@@ -61,4 +62,5 @@ enum class NativeFailure {
|
||||
interface ShellRepository {
|
||||
val snapshot: StateFlow<ShellSnapshot>
|
||||
fun selectTab(tab: ShellTab)
|
||||
fun refresh() {}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"android": {
|
||||
"rustls:rustls-platform-verifier:0.1.1": "MIT OR Apache-2.0",
|
||||
"androidx.activity:activity-compose:1.12.0": "Apache-2.0",
|
||||
"androidx.activity:activity-compose:1.13.0": "Apache-2.0",
|
||||
"androidx.activity:activity-ktx:1.12.0": "Apache-2.0",
|
||||
|
||||
@@ -281,4 +281,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||
org.ow2.asm:asm:9.9=androidLintTool
|
||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
||||
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
|
||||
@@ -0,0 +1,360 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Secrets survive configuration changes in memory, never in a saved-state Bundle. */
|
||||
@HiltViewModel
|
||||
class SetupFormViewModel @Inject constructor() : ViewModel() {
|
||||
var passphrase by mutableStateOf("")
|
||||
var armor by mutableStateOf("")
|
||||
var token by mutableStateOf("")
|
||||
fun clearSecrets() {
|
||||
passphrase = ""
|
||||
armor = ""
|
||||
token = ""
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
repository: OnboardingRepository,
|
||||
secrets: SetupFormViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val state by repository.setup.collectAsStateWithLifecycle()
|
||||
var step by rememberSaveable { mutableStateOf("choice") }
|
||||
var clone by rememberSaveable { mutableStateOf(false) }
|
||||
var userId by rememberSaveable { mutableStateOf("") }
|
||||
var server by rememberSaveable { mutableStateOf("") }
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var path by rememberSaveable { mutableStateOf("") }
|
||||
var key by rememberSaveable { mutableStateOf("") }
|
||||
var branch by rememberSaveable { mutableStateOf("") }
|
||||
var reuse by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedKey = key.ifEmpty { state.selectedKey }
|
||||
val selectedBranch = branch.ifEmpty { state.selectedBranch }
|
||||
// A restarted process reconstructs prepared keys; transport secrets must be entered again.
|
||||
val page = when {
|
||||
state.connected && step == "remote" -> "ready"
|
||||
state.configured && step !in setOf("remote", "token") -> "ready"
|
||||
state.branches.isNotEmpty() -> "branch"
|
||||
else -> step
|
||||
}
|
||||
Column(
|
||||
modifier.widthIn(max = 640.dp).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
state.title,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
},
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
state.detail,
|
||||
color = if (state.failure) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator()
|
||||
TextButton(onClick = repository::cancel) { Text(stringResource(R.string.setup_cancel)) }
|
||||
} else {
|
||||
when (page) {
|
||||
"choice" -> {
|
||||
Text(stringResource(R.string.setup_manual_alternative))
|
||||
Button(onClick = {
|
||||
clone = false
|
||||
step = "key"
|
||||
}) { Text(stringResource(R.string.setup_local)) }
|
||||
Button(onClick = {
|
||||
clone = true
|
||||
step = "key"
|
||||
}) { Text(stringResource(R.string.setup_clone)) }
|
||||
}
|
||||
"key" -> {
|
||||
Text(stringResource(R.string.setup_choose_key))
|
||||
state.keys.forEach { item ->
|
||||
Row(
|
||||
Modifier.selectable(
|
||||
selected = selectedKey == item.fingerprint,
|
||||
role = Role.RadioButton,
|
||||
onClick = { key = item.fingerprint }
|
||||
)
|
||||
) {
|
||||
RadioButton(selected = selectedKey == item.fingerprint, onClick = null)
|
||||
Column {
|
||||
Text(item.userIds.joinToString())
|
||||
Text(item.fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.keys.isNotEmpty()) {
|
||||
if (!clone) {
|
||||
SecretField(
|
||||
stringResource(R.string.setup_passphrase),
|
||||
secrets.passphrase
|
||||
) {
|
||||
secrets.passphrase =
|
||||
it
|
||||
}
|
||||
}
|
||||
Button(onClick = {
|
||||
if (clone) {
|
||||
step = "remote"
|
||||
} else {
|
||||
repository.createLocal(selectedKey, secrets.passphrase)
|
||||
secrets.clearSecrets()
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (clone) R.string.setup_continue else R.string.setup_create
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(onClick = {
|
||||
step = "generate"
|
||||
}) { Text(stringResource(R.string.setup_generate)) }
|
||||
TextButton(onClick = {
|
||||
step = "import"
|
||||
}) { Text(stringResource(R.string.setup_import)) }
|
||||
}
|
||||
"generate" -> {
|
||||
Field(stringResource(R.string.setup_user_id), userId, onChange = {
|
||||
userId = it
|
||||
})
|
||||
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
|
||||
secrets.passphrase =
|
||||
it
|
||||
}
|
||||
Button(onClick = {
|
||||
repository.generate(userId, secrets.passphrase)
|
||||
secrets.armor = ""
|
||||
secrets.token = ""
|
||||
step =
|
||||
"key"
|
||||
}) {
|
||||
Text(stringResource(R.string.setup_generate))
|
||||
}
|
||||
}
|
||||
"import" -> {
|
||||
Text(stringResource(R.string.setup_import_help))
|
||||
SecretField(
|
||||
stringResource(R.string.setup_armor),
|
||||
secrets.armor,
|
||||
singleLine = false
|
||||
) {
|
||||
secrets.armor =
|
||||
it
|
||||
}
|
||||
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
|
||||
secrets.passphrase =
|
||||
it
|
||||
}
|
||||
Button(onClick = {
|
||||
repository.importKey(secrets.armor, secrets.passphrase)
|
||||
secrets.armor = ""
|
||||
secrets.token = ""
|
||||
step =
|
||||
"key"
|
||||
}) {
|
||||
Text(stringResource(R.string.setup_import))
|
||||
}
|
||||
}
|
||||
"remote" -> {
|
||||
Field(stringResource(R.string.setup_server), server, KeyboardType.Uri) {
|
||||
server =
|
||||
it
|
||||
}
|
||||
Field(stringResource(R.string.setup_account), account, onChange = {
|
||||
account = it
|
||||
})
|
||||
Field(stringResource(R.string.setup_repository), path, onChange = { path = it })
|
||||
SecretField(stringResource(R.string.setup_token), secrets.token) {
|
||||
secrets.token =
|
||||
it
|
||||
}
|
||||
Button(onClick = {
|
||||
if (state.configured) {
|
||||
repository.connect(server, account, path, secrets.token)
|
||||
} else {
|
||||
repository.discover(server, account, path, secrets.token)
|
||||
}
|
||||
secrets.token = ""
|
||||
secrets.armor = ""
|
||||
}) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.configured) {
|
||||
R.string.setup_connect
|
||||
} else {
|
||||
R.string.setup_discover
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"branch" -> {
|
||||
state.branches.forEach { item ->
|
||||
Row(
|
||||
Modifier.selectable(
|
||||
selected = selectedBranch == item,
|
||||
role = Role.RadioButton,
|
||||
onClick = { branch = item }
|
||||
)
|
||||
) {
|
||||
RadioButton(selected = selectedBranch == item, onClick = null)
|
||||
Text(item)
|
||||
}
|
||||
}
|
||||
SecretField(stringResource(R.string.setup_passphrase), secrets.passphrase) {
|
||||
secrets.passphrase =
|
||||
it
|
||||
}
|
||||
Row(
|
||||
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
|
||||
reuse =
|
||||
it
|
||||
})
|
||||
) {
|
||||
Checkbox(checked = reuse, onCheckedChange = null)
|
||||
Text(stringResource(R.string.setup_reuse))
|
||||
}
|
||||
Button(onClick = {
|
||||
repository.cloneStore(
|
||||
selectedBranch,
|
||||
selectedKey,
|
||||
secrets.passphrase,
|
||||
reuse
|
||||
)
|
||||
secrets.clearSecrets()
|
||||
}) {
|
||||
Text(stringResource(R.string.setup_clone))
|
||||
}
|
||||
}
|
||||
"ready" -> {
|
||||
if (!state.connected) {
|
||||
Button(onClick = {
|
||||
step = "remote"
|
||||
}) { Text(stringResource(R.string.setup_add_remote)) }
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
step = "token"
|
||||
}) { Text(stringResource(R.string.setup_replace_token)) }
|
||||
}
|
||||
}
|
||||
"token" -> {
|
||||
Field(stringResource(R.string.setup_account), account, onChange = {
|
||||
account = it
|
||||
})
|
||||
SecretField(stringResource(R.string.setup_token), secrets.token) {
|
||||
secrets.token =
|
||||
it
|
||||
}
|
||||
Button(onClick = {
|
||||
repository.replaceToken(account, secrets.token)
|
||||
secrets.clearSecrets()
|
||||
}) { Text(stringResource(R.string.setup_replace_token)) }
|
||||
}
|
||||
}
|
||||
if (page !in setOf("choice", "ready")) {
|
||||
TextButton(onClick = {
|
||||
repository.refresh()
|
||||
secrets.clearSecrets()
|
||||
step = if (state.configured) "ready" else "choice"
|
||||
clone = false
|
||||
}) { Text(stringResource(R.string.setup_back)) }
|
||||
}
|
||||
if (state.failure) {
|
||||
TextButton(onClick = repository::refresh) {
|
||||
Text(stringResource(R.string.setup_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Field(
|
||||
label: String,
|
||||
value: String,
|
||||
keyboard: KeyboardType = KeyboardType.Text,
|
||||
onChange: (String) -> Unit
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value,
|
||||
onChange,
|
||||
label = {
|
||||
Text(label)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboard
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecretField(
|
||||
label: String,
|
||||
value: String,
|
||||
singleLine: Boolean = true,
|
||||
onChange: (String) -> Unit
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value,
|
||||
onChange,
|
||||
label = {
|
||||
Text(label)
|
||||
},
|
||||
singleLine = singleLine,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
maxLines = if (singleLine) 1 else 5
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import de.rfc1437.ironstorage.core.ShellState
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
|
||||
@Composable
|
||||
fun ShellScreen(viewModel: ShellViewModel) {
|
||||
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
@@ -75,13 +75,20 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(detail)
|
||||
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
|
||||
if (!snapshot.fixture && snapshot.nativeFailure == null &&
|
||||
snapshot.storageRecovery == null &&
|
||||
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||
) {
|
||||
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||
} else {
|
||||
Text(
|
||||
title,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(detail)
|
||||
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ShellViewModel @Inject constructor(private val repository: ShellRepository) : ViewModel() {
|
||||
class ShellViewModel @Inject constructor(
|
||||
private val repository: ShellRepository,
|
||||
val onboarding: OnboardingRepository
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
|
||||
fun selectTab(tab: ShellTab) = repository.selectTab(tab)
|
||||
|
||||
@@ -6,4 +6,28 @@
|
||||
<string name="native_unsupported_device">This device is not supported by this app build.</string>
|
||||
<string name="native_update_required">The installed app components could not be loaded or do not match. Install a complete update without clearing app data.</string>
|
||||
<string name="native_operation_failed">The app could not complete this operation. Try again.</string>
|
||||
<string name="setup_cancel">Cancel setup</string>
|
||||
<string name="setup_manual_alternative">No server, camera, or biometric enrollment is required for local setup. Import a private key manually or generate one here.</string>
|
||||
<string name="setup_local">Create local store</string>
|
||||
<string name="setup_clone">Clone existing store</string>
|
||||
<string name="setup_choose_key">Select a private GPG encryption key</string>
|
||||
<string name="setup_passphrase">GPG passphrase</string>
|
||||
<string name="setup_continue">Continue</string>
|
||||
<string name="setup_create">Create encrypted local store</string>
|
||||
<string name="setup_generate">Generate GPG key</string>
|
||||
<string name="setup_import">Import private GPG key</string>
|
||||
<string name="setup_user_id">Key name and email</string>
|
||||
<string name="setup_import_help">Paste one armored private key. It remains in app-private storage; the passphrase is not saved.</string>
|
||||
<string name="setup_armor">Armored private key</string>
|
||||
<string name="setup_server">HTTPS server URL</string>
|
||||
<string name="setup_account">Account</string>
|
||||
<string name="setup_repository">Repository path (owner/repository)</string>
|
||||
<string name="setup_token">Application token</string>
|
||||
<string name="setup_connect">Connect local store</string>
|
||||
<string name="setup_discover">Discover branches</string>
|
||||
<string name="setup_reuse">Use matching existing clone</string>
|
||||
<string name="setup_add_remote">Add HTTPS synchronization</string>
|
||||
<string name="setup_replace_token">Replace application token</string>
|
||||
<string name="setup_back">Back to setup choices</string>
|
||||
<string name="setup_retry">Reload setup state</string>
|
||||
</resources>
|
||||
@@ -21,11 +21,13 @@ rust = "1.97.1"
|
||||
cargoNdk = "4.1.2"
|
||||
jna = "5.19.1"
|
||||
annotation = "1.11.0"
|
||||
rustlsVerifier = "0.1.1"
|
||||
|
||||
biometric = "1.1.0"
|
||||
fragment = "1.9.1"
|
||||
|
||||
[libraries]
|
||||
rustls-verifier = { group = "rustls", name = "rustls-platform-verifier", version.ref = "rustlsVerifier" }
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
|
||||
@@ -2284,6 +2284,11 @@
|
||||
<sha256 value="7220ede61026596fbc720ee4b93246fa2d14f328058532b59ef053de397c7d83" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre">
|
||||
<artifact name="guava-parent-33.4.0-jre.pom">
|
||||
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.guava" name="guava-parent" version="33.6.0-jre">
|
||||
<artifact name="guava-parent-33.6.0-jre.pom">
|
||||
<sha256 value="374bd31f61b1cf612bee9ab2e4d70bbdf77dd85a49b431f809d4fbdc901f2dd4" origin="Generated by Gradle"/>
|
||||
@@ -3774,6 +3779,11 @@
|
||||
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0">
|
||||
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
|
||||
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.1">
|
||||
<artifact name="kotlinx-coroutines-bom-1.8.1.pom">
|
||||
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
|
||||
@@ -4114,14 +4124,12 @@
|
||||
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre">
|
||||
<artifact name="guava-parent-33.4.0-jre.pom">
|
||||
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
<component group="rustls" name="rustls-platform-verifier" version="0.1.1">
|
||||
<artifact name="rustls-platform-verifier-0.1.1.aar">
|
||||
<sha256 value="667292cadd8fa589229dd0f716541236a761f29b774930868d218175633830fd" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0">
|
||||
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
|
||||
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
<artifact name="rustls-platform-verifier-0.1.1.pom">
|
||||
<sha256 value="5468629ab3793f4768c0527b8ecca25219a00b5d9503d1ad657cc664c87e9081" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
</components>
|
||||
|
||||
@@ -216,4 +216,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||
org.ow2.asm:asm:9.9=androidLintTool
|
||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
||||
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
|
||||
@@ -36,6 +36,7 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.rustls.verifier)
|
||||
implementation(variantOf(libs.jna) { artifactType("aar") })
|
||||
implementation(libs.annotation)
|
||||
testImplementation(libs.junit)
|
||||
|
||||
@@ -5,3 +5,6 @@
|
||||
-dontwarn java.awt.GraphicsEnvironment
|
||||
-dontwarn java.awt.HeadlessException
|
||||
-dontwarn java.awt.Window
|
||||
|
||||
-keep class de.rfc1437.ironstorage.bridge.NativeTls { *; }
|
||||
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
|
||||
@@ -152,4 +152,5 @@ org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||
org.ow2.asm:asm:9.9=androidLintTool
|
||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
||||
rustls:rustls-platform-verifier:0.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package de.rfc1437.ironstorage.bridge
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretBackend
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretLocator
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileSecretProtection
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidOnboardingTest {
|
||||
@Test fun realPublicHttpsDiscoveryAndConnectionUseThePlatformVerifier() {
|
||||
assumeTrue(
|
||||
InstrumentationRegistry.getArguments().getString("ironstorage_https_integration") ==
|
||||
"true"
|
||||
)
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val id = "https-onboarding-${UUID.randomUUID()}"
|
||||
val directories = MobilePrivateDirectories(
|
||||
File(context.filesDir, id).apply { mkdirs() }.canonicalPath,
|
||||
File(context.noBackupFilesDir, id).apply { mkdirs() }.canonicalPath,
|
||||
File(context.cacheDir, id).apply { mkdirs() }.canonicalPath
|
||||
)
|
||||
val backend = MemoryBackend()
|
||||
NativeRuntime(context).privateStorage(directories).use { runtime ->
|
||||
runtime.androidOnboarding(backend).use { setup ->
|
||||
setup.remote(
|
||||
"https://github.com",
|
||||
"public-fixture",
|
||||
"octocat/Hello-World",
|
||||
"public-test-token"
|
||||
).use { remote ->
|
||||
assertTrue(remote.discover().branches.isNotEmpty())
|
||||
}
|
||||
assertFalse(setup.status().configured)
|
||||
val key =
|
||||
requireNotNull(
|
||||
setup.generate(
|
||||
"HTTPS fixture <fixture@example.test>",
|
||||
"public-test-passphrase"
|
||||
).selectedKey
|
||||
)
|
||||
setup.setupLocal(key, "public-test-passphrase")
|
||||
setup.remote(
|
||||
"https://github.com",
|
||||
"public-fixture",
|
||||
"octocat/Hello-World",
|
||||
"public-test-token"
|
||||
).use {
|
||||
it.connect()
|
||||
}
|
||||
assertTrue(setup.status().connected)
|
||||
setup.replaceToken("public-fixture", "public-replacement-token")
|
||||
assertEquals(2, backend.records.size)
|
||||
val config = File(runtime.layout().configuration).readText()
|
||||
assertFalse(config.contains("public-test-token"))
|
||||
assertFalse(config.contains("public-replacement-token"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun realRustSetupSurvivesRecreationAndKeepsTokensOutOfConfiguration() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val id = "onboarding-${UUID.randomUUID()}"
|
||||
val directories = MobilePrivateDirectories(
|
||||
File(context.filesDir, id).apply { mkdirs() }.canonicalPath,
|
||||
File(context.noBackupFilesDir, id).apply { mkdirs() }.canonicalPath,
|
||||
File(context.cacheDir, id).apply { mkdirs() }.canonicalPath
|
||||
)
|
||||
val native = NativeRuntime(context)
|
||||
val backend = MemoryBackend()
|
||||
native.privateStorage(directories).use { runtime ->
|
||||
runtime.androidOnboarding(backend).use { setup ->
|
||||
assertFalse(setup.status().configured)
|
||||
val prepared = setup.generate(
|
||||
"Emulator fixture <fixture@example.test>",
|
||||
"public-test-passphrase"
|
||||
)
|
||||
assertEquals(1, prepared.keys.size)
|
||||
assertFalse(prepared.configured)
|
||||
}
|
||||
}
|
||||
native.privateStorage(directories).use { runtime ->
|
||||
runtime.androidOnboarding(backend).use { setup ->
|
||||
val key = requireNotNull(setup.status().selectedKey)
|
||||
setup.setupLocal(key, "public-test-passphrase")
|
||||
assertTrue(setup.status().configured)
|
||||
assertFalse(setup.status().connected)
|
||||
val unsupported = assertThrows(MobileOnboardingFfiException.Failed::class.java) {
|
||||
setup.remote(
|
||||
"ssh://example.test",
|
||||
"alice",
|
||||
"team/passwords",
|
||||
"public-test-token"
|
||||
)
|
||||
}
|
||||
assertEquals(MobileOnboardingErrorKind.UNSUPPORTED_REMOTE, unsupported.kind)
|
||||
setup.remote(
|
||||
"https://example.test",
|
||||
"alice",
|
||||
"team/passwords",
|
||||
"public-test-token"
|
||||
).use {
|
||||
assertThrows(MobileOnboardingFfiException.Failed::class.java) { it.connect() }
|
||||
}
|
||||
assertFalse(setup.status().connected)
|
||||
val config = File(runtime.layout().configuration).readText()
|
||||
assertFalse(config.contains("public-test-passphrase"))
|
||||
assertFalse(config.contains("public-test-token"))
|
||||
assertFalse(config.contains("public-replacement-token"))
|
||||
assertTrue(File(runtime.layout().vault, ".gpg-id").readText().contains(key))
|
||||
assertEquals(1, backend.records.size)
|
||||
}
|
||||
}
|
||||
native.privateStorage(directories).use { runtime ->
|
||||
runtime.androidOnboarding(backend).use { assertTrue(it.status().configured) }
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryBackend : MobileSecretBackend {
|
||||
val records = mutableMapOf<MobileSecretLocator, ByteArray>()
|
||||
override fun create(
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection,
|
||||
value: ByteArray
|
||||
) {
|
||||
if (records.containsKey(locator)) throw MobileSecretException.AlreadyExists()
|
||||
records[locator] = value.copyOf()
|
||||
value.fill(0)
|
||||
}
|
||||
override fun retrieve(
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection
|
||||
): ByteArray = records[locator]?.copyOf() ?: throw MobileSecretException.Missing()
|
||||
override fun replace(
|
||||
locator: MobileSecretLocator,
|
||||
protection: MobileSecretProtection,
|
||||
value: ByteArray
|
||||
) {
|
||||
if (!records.containsKey(locator)) throw MobileSecretException.Missing()
|
||||
records[locator]?.fill(0)
|
||||
records[locator] = value.copyOf()
|
||||
value.fill(0)
|
||||
}
|
||||
override fun delete(locator: MobileSecretLocator, protection: MobileSecretProtection) {
|
||||
(records.remove(locator) ?: throw MobileSecretException.Missing()).fill(0)
|
||||
}
|
||||
override fun lock() = Unit
|
||||
override fun unlock() = Unit
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import java.util.zip.ZipFile
|
||||
|
||||
/** OS library loading only. Callers schedule all native calls off the main thread. */
|
||||
class NativeRuntime(context: Context) {
|
||||
private val applicationInfo = context.applicationContext.applicationInfo
|
||||
private val applicationContext = context.applicationContext
|
||||
private val applicationInfo = applicationContext.applicationInfo
|
||||
private val lock = Any()
|
||||
private var initialized = false
|
||||
private var initializationFailure: NativeFailureKind? = null
|
||||
@@ -45,6 +46,7 @@ class NativeRuntime(context: Context) {
|
||||
}
|
||||
if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary)
|
||||
System.loadLibrary(LIBRARY)
|
||||
NativeTls.initialize(applicationContext)
|
||||
uniffiEnsureInitialized()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.rfc1437.ironstorage.bridge
|
||||
|
||||
/** Pass the OS Application to the native verifier; no trust decisions live here. */
|
||||
internal object NativeTls {
|
||||
external fun initialize(context: Any)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import groovy.json.JsonSlurper
|
||||
import java.io.File
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
@@ -10,6 +13,25 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// The official verifier JVM support is shipped in the checksum-pinned Cargo crate.
|
||||
val cargo = providers.environmentVariable("CARGO").orElse(
|
||||
providers.environmentVariable("PATH").map { path ->
|
||||
path.split(File.pathSeparatorChar).map { File(it, "cargo") }
|
||||
.firstOrNull { it.isFile && it.canExecute() }?.absolutePath
|
||||
?: error("Cargo is required for the Android TLS verifier")
|
||||
}
|
||||
)
|
||||
val metadata = providers.exec {
|
||||
workingDir = file("..")
|
||||
commandLine(cargo.get(), "+1.97.1", "metadata", "--locked", "--offline", "--format-version", "1", "--filter-platform", "aarch64-linux-android")
|
||||
}.standardOutput.asText
|
||||
val packages = (JsonSlurper().parseText(metadata.get()) as Map<*, *>)["packages"] as List<*>
|
||||
val verifier = packages.map { it as Map<*, *> }.single { it["name"] == "rustls-platform-verifier-android" }
|
||||
check(verifier["version"] == "0.1.1") { "Update the verifier catalog pin with its Cargo component" }
|
||||
maven {
|
||||
url = uri(File(verifier["manifest_path"] as String).parentFile.resolve("maven"))
|
||||
content { includeModule("rustls", "rustls-platform-verifier") }
|
||||
}
|
||||
}
|
||||
}
|
||||
rootProject.name = "IronStorage"
|
||||
|
||||
Reference in new issue
Block a user