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
Generated
+2
@@ -5440,6 +5440,8 @@ name = "ironstorage-android"
|
|||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ironstorage-mobile",
|
"ironstorage-mobile",
|
||||||
|
"jni",
|
||||||
|
"rustls-platform-verifier",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+6
-5
@@ -1,10 +1,10 @@
|
|||||||
# Native Android build
|
# Native Android build
|
||||||
|
|
||||||
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
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
|
callbacks and build gates. Both variants invoke Rust through the generated
|
||||||
UniFFI API. Onboarding and the feature screens remain owned by their milestone
|
UniFFI API. Local-first and HTTPS onboarding is described in
|
||||||
issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
|
[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.
|
ARM-only runner/toolchain policy and artifact audits.
|
||||||
|
|
||||||
## Reproducible setup
|
## Reproducible setup
|
||||||
@@ -54,8 +54,9 @@ code cannot conceal a domain implementation.
|
|||||||
|
|
||||||
Debug controls invoke the storage-owned MobileShell::fixture through the native
|
Debug controls invoke the storage-owned MobileShell::fixture through the native
|
||||||
API for Loading/Empty/Ready/Locked/Error. A build-generated fixture inventory is
|
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
|
used only as the test oracle. Release loads the real Rust private-storage state without debug controls.
|
||||||
debug controls; repository initialization is owned by #90. Feature code consumes
|
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.
|
only the facade, including typed native recovery states.
|
||||||
|
|
||||||
## Identity, signing and update
|
## 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=androidLintTool
|
||||||
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
|
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
|
||||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
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
|
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.NativeFixtureShellRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellState
|
import de.rfc1437.ironstorage.core.ShellState
|
||||||
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
import de.rfc1437.ironstorage.features.ShellScreen
|
import de.rfc1437.ironstorage.features.ShellScreen
|
||||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
|
fun VariantContent(
|
||||||
|
viewModel: ShellViewModel,
|
||||||
|
repository: ShellRepository,
|
||||||
|
setupForm: SetupFormViewModel
|
||||||
|
) {
|
||||||
Column(Modifier.statusBarsPadding()) {
|
Column(Modifier.statusBarsPadding()) {
|
||||||
Row(Modifier.horizontalScroll(rememberScrollState())) {
|
Row(Modifier.horizontalScroll(rememberScrollState())) {
|
||||||
ShellState.entries.forEach { state ->
|
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">
|
<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" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<application
|
<application
|
||||||
android:name=".IronStorageApplication"
|
android:name=".IronStorageApplication"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -18,6 +19,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var repository: ShellRepository
|
@Inject lateinit var repository: ShellRepository
|
||||||
|
|
||||||
|
private val setupForm: SetupFormViewModel by viewModels()
|
||||||
|
|
||||||
private val viewModel: ShellViewModel by viewModels()
|
private val viewModel: ShellViewModel by viewModels()
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -31,7 +34,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
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.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
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.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||||
import de.rfc1437.ironstorage.platform.privateDirectories
|
import de.rfc1437.ironstorage.platform.privateDirectories
|
||||||
@@ -18,6 +21,25 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object ShellModule {
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter =
|
fun keystore(@ApplicationContext context: Context): AndroidKeystoreAdapter =
|
||||||
@@ -25,13 +47,9 @@ object ShellModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun shellRepository(
|
fun shellRepository(session: NativePrivateSession): ShellRepository =
|
||||||
@ApplicationContext context: Context,
|
NativeFixtureShellRepository(
|
||||||
keystore: AndroidKeystoreAdapter
|
session,
|
||||||
): ShellRepository = NativeFixtureShellRepository(
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
context,
|
)
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
|
||||||
{ privateDirectories(context) },
|
|
||||||
keystore
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,16 @@ package de.rfc1437.ironstorage
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
import de.rfc1437.ironstorage.features.ShellScreen
|
import de.rfc1437.ironstorage.features.ShellScreen
|
||||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) {
|
fun VariantContent(
|
||||||
ShellScreen(viewModel)
|
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-tree:9.9=androidLintTool
|
||||||
org.ow2.asm:asm:9.9=androidLintTool
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
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
|
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
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||||
import de.rfc1437.ironstorage.bridge.NativeFailureKind
|
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.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.MobilePrivateStorageErrorKind
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
|
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. */
|
/** Rust-owned private initialization; debug controls can switch to explicit fixtures. */
|
||||||
class NativeFixtureShellRepository internal constructor(
|
class NativeFixtureShellRepository internal constructor(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val loadCurrent: (suspend () -> ShellSnapshot)? = null,
|
||||||
private val load: suspend (ShellState) -> ShellSnapshot
|
private val load: suspend (ShellState) -> ShellSnapshot
|
||||||
) : ShellRepository {
|
) : ShellRepository {
|
||||||
constructor(
|
constructor(session: NativePrivateSession, scope: CoroutineScope) : this(
|
||||||
context: Context,
|
scope,
|
||||||
scope: CoroutineScope,
|
{ nativeLoader(session, true)(ShellState.Empty) },
|
||||||
directories: () -> PrivateDirectories,
|
nativeLoader(session)
|
||||||
keystore: AndroidKeystore
|
)
|
||||||
) :
|
|
||||||
this(scope, nativeLoader(context, directories, keystore))
|
|
||||||
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
|
private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList()))
|
||||||
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
|
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
|
||||||
private val revision = AtomicLong()
|
private val revision = AtomicLong()
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
showFixture(ShellState.Empty)
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun selectTab(tab: ShellTab) {
|
override fun selectTab(tab: ShellTab) {
|
||||||
@@ -48,17 +43,26 @@ class NativeFixtureShellRepository internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun showFixture(state: ShellState) {
|
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()
|
val operation = revision.incrementAndGet()
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
val result = try {
|
val result = try {
|
||||||
load(state)
|
loader()
|
||||||
} catch (error: NativeBridgeException) {
|
} catch (error: NativeBridgeException) {
|
||||||
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
|
ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade())
|
||||||
}
|
}
|
||||||
if (revision.get() == operation) {
|
if (revision.get() == operation) {
|
||||||
mutableSnapshot.update {
|
mutableSnapshot.update {
|
||||||
result.copy(
|
result.copy(
|
||||||
|
fixture = fixture,
|
||||||
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
|
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -68,31 +72,16 @@ class NativeFixtureShellRepository internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun nativeLoader(
|
private fun nativeLoader(
|
||||||
context: Context,
|
session: NativePrivateSession,
|
||||||
directoryProvider: () -> PrivateDirectories,
|
current: Boolean = false
|
||||||
keystore: AndroidKeystore
|
|
||||||
): suspend (ShellState) -> ShellSnapshot {
|
): suspend (ShellState) -> ShellSnapshot {
|
||||||
val runtime = NativeRuntime(context)
|
val runtime = session.runtime
|
||||||
var storage: MobilePrivateRuntime? = null
|
|
||||||
var firstLoad = true
|
|
||||||
return { state ->
|
return { state ->
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var recovery: StorageRecovery? = null
|
var recovery: StorageRecovery? = null
|
||||||
val shell = if (firstLoad) {
|
val shell = if (current) {
|
||||||
firstLoad = false
|
|
||||||
try {
|
try {
|
||||||
val directories = directoryProvider()
|
val initialized = session.storage()
|
||||||
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)
|
|
||||||
recovery = initialized.status().recovery?.toFacade()
|
recovery = initialized.status().recovery?.toFacade()
|
||||||
initialized.shell()
|
initialized.shell()
|
||||||
} catch (error: MobilePrivateStorageFfiException.Failed) {
|
} 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 state: ShellState,
|
||||||
val pages: List<ShellPage>,
|
val pages: List<ShellPage>,
|
||||||
val selectedTab: ShellTab = ShellTab.Home,
|
val selectedTab: ShellTab = ShellTab.Home,
|
||||||
|
val fixture: Boolean = false,
|
||||||
val nativeFailure: NativeFailure? = null,
|
val nativeFailure: NativeFailure? = null,
|
||||||
val storageRecovery: StorageRecovery? = null
|
val storageRecovery: StorageRecovery? = null
|
||||||
)
|
)
|
||||||
@@ -61,4 +62,5 @@ enum class NativeFailure {
|
|||||||
interface ShellRepository {
|
interface ShellRepository {
|
||||||
val snapshot: StateFlow<ShellSnapshot>
|
val snapshot: StateFlow<ShellSnapshot>
|
||||||
fun selectTab(tab: ShellTab)
|
fun selectTab(tab: ShellTab)
|
||||||
|
fun refresh() {}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"android": {
|
"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.12.0": "Apache-2.0",
|
||||||
"androidx.activity:activity-compose:1.13.0": "Apache-2.0",
|
"androidx.activity:activity-compose:1.13.0": "Apache-2.0",
|
||||||
"androidx.activity:activity-ktx:1.12.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-tree:9.9=androidLintTool
|
||||||
org.ow2.asm:asm:9.9=androidLintTool
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
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
|
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
|
import de.rfc1437.ironstorage.core.ShellTab
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShellScreen(viewModel: ShellViewModel) {
|
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||||
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
@@ -75,13 +75,20 @@ fun ShellScreen(viewModel: ShellViewModel) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
if (!snapshot.fixture && snapshot.nativeFailure == null &&
|
||||||
title,
|
snapshot.storageRecovery == null &&
|
||||||
modifier = Modifier.semantics { heading() },
|
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||||
style = MaterialTheme.typography.headlineSmall
|
) {
|
||||||
)
|
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||||
Text(detail)
|
} else {
|
||||||
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
|
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 androidx.lifecycle.ViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellTab
|
import de.rfc1437.ironstorage.core.ShellTab
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@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
|
val snapshot = repository.snapshot
|
||||||
|
|
||||||
fun selectTab(tab: ShellTab) = repository.selectTab(tab)
|
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_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_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="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>
|
</resources>
|
||||||
@@ -21,11 +21,13 @@ rust = "1.97.1"
|
|||||||
cargoNdk = "4.1.2"
|
cargoNdk = "4.1.2"
|
||||||
jna = "5.19.1"
|
jna = "5.19.1"
|
||||||
annotation = "1.11.0"
|
annotation = "1.11.0"
|
||||||
|
rustlsVerifier = "0.1.1"
|
||||||
|
|
||||||
biometric = "1.1.0"
|
biometric = "1.1.0"
|
||||||
fragment = "1.9.1"
|
fragment = "1.9.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
rustls-verifier = { group = "rustls", name = "rustls-platform-verifier", version.ref = "rustlsVerifier" }
|
||||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
|
||||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
|||||||
@@ -2284,6 +2284,11 @@
|
|||||||
<sha256 value="7220ede61026596fbc720ee4b93246fa2d14f328058532b59ef053de397c7d83" origin="Generated by Gradle"/>
|
<sha256 value="7220ede61026596fbc720ee4b93246fa2d14f328058532b59ef053de397c7d83" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
</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">
|
<component group="com.google.guava" name="guava-parent" version="33.6.0-jre">
|
||||||
<artifact name="guava-parent-33.6.0-jre.pom">
|
<artifact name="guava-parent-33.6.0-jre.pom">
|
||||||
<sha256 value="374bd31f61b1cf612bee9ab2e4d70bbdf77dd85a49b431f809d4fbdc901f2dd4" origin="Generated by Gradle"/>
|
<sha256 value="374bd31f61b1cf612bee9ab2e4d70bbdf77dd85a49b431f809d4fbdc901f2dd4" origin="Generated by Gradle"/>
|
||||||
@@ -3774,6 +3779,11 @@
|
|||||||
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
|
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
</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">
|
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.1">
|
||||||
<artifact name="kotlinx-coroutines-bom-1.8.1.pom">
|
<artifact name="kotlinx-coroutines-bom-1.8.1.pom">
|
||||||
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
|
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
|
||||||
@@ -4114,14 +4124,12 @@
|
|||||||
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
|
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
</component>
|
||||||
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre">
|
<component group="rustls" name="rustls-platform-verifier" version="0.1.1">
|
||||||
<artifact name="guava-parent-33.4.0-jre.pom">
|
<artifact name="rustls-platform-verifier-0.1.1.aar">
|
||||||
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
<sha256 value="667292cadd8fa589229dd0f716541236a761f29b774930868d218175633830fd" origin="Generated by Gradle"/>
|
||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
<artifact name="rustls-platform-verifier-0.1.1.pom">
|
||||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0">
|
<sha256 value="5468629ab3793f4768c0527b8ecca25219a00b5d9503d1ad657cc664c87e9081" origin="Generated by Gradle"/>
|
||||||
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
|
|
||||||
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
|
||||||
</artifact>
|
</artifact>
|
||||||
</component>
|
</component>
|
||||||
</components>
|
</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-tree:9.9=androidLintTool
|
||||||
org.ow2.asm:asm:9.9=androidLintTool
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
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
|
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 {
|
dependencies {
|
||||||
|
implementation(libs.rustls.verifier)
|
||||||
implementation(variantOf(libs.jna) { artifactType("aar") })
|
implementation(variantOf(libs.jna) { artifactType("aar") })
|
||||||
implementation(libs.annotation)
|
implementation(libs.annotation)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
|||||||
@@ -5,3 +5,6 @@
|
|||||||
-dontwarn java.awt.GraphicsEnvironment
|
-dontwarn java.awt.GraphicsEnvironment
|
||||||
-dontwarn java.awt.HeadlessException
|
-dontwarn java.awt.HeadlessException
|
||||||
-dontwarn java.awt.Window
|
-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-tree:9.9=androidLintTool
|
||||||
org.ow2.asm:asm:9.9=androidLintTool
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
org.slf4j:slf4j-api:2.0.7=ktlint
|
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
|
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. */
|
/** OS library loading only. Callers schedule all native calls off the main thread. */
|
||||||
class NativeRuntime(context: Context) {
|
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 val lock = Any()
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
private var initializationFailure: NativeFailureKind? = null
|
private var initializationFailure: NativeFailureKind? = null
|
||||||
@@ -45,6 +46,7 @@ class NativeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary)
|
if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary)
|
||||||
System.loadLibrary(LIBRARY)
|
System.loadLibrary(LIBRARY)
|
||||||
|
NativeTls.initialize(applicationContext)
|
||||||
uniffiEnsureInitialized()
|
uniffiEnsureInitialized()
|
||||||
initialized = true
|
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 {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -10,6 +13,25 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
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"
|
rootProject.name = "IronStorage"
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ crate-type = ["lib", "cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ironstorage-mobile = { path = "../mobile-bridge" }
|
ironstorage-mobile = { path = "../mobile-bridge" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = "=0.22.4"
|
||||||
|
rustls-platform-verifier = "=0.7.0"
|
||||||
@@ -4,3 +4,6 @@
|
|||||||
//! The shared component owns every conversion; no interface is copied here.
|
//! The shared component owns every conversion; no interface is copied here.
|
||||||
|
|
||||||
ironstorage_mobile::uniffi_reexport_scaffolding!();
|
ironstorage_mobile::uniffi_reexport_scaffolding!();
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod tls;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! OS certificate-verifier initialization only; Git/TLS policy stays in storage.
|
||||||
|
//! The JNI macro supplies a scoped valid environment and catches boundary panics;
|
||||||
|
//! rustls retains global Context/class-loader references before this scope ends.
|
||||||
|
use jni::{Env, objects::JObject};
|
||||||
|
|
||||||
|
const _: jni::NativeMethod = jni::native_method! {
|
||||||
|
java_type = "de.rfc1437.ironstorage.bridge.NativeTls",
|
||||||
|
extern fn initialize(context: JObject),
|
||||||
|
};
|
||||||
|
|
||||||
|
fn initialize<'local>(
|
||||||
|
env: &mut Env<'local>,
|
||||||
|
_this: JObject<'local>,
|
||||||
|
context: JObject<'local>,
|
||||||
|
) -> Result<(), jni::errors::Error> {
|
||||||
|
rustls_platform_verifier::android::init_with_env(env, context)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! Mechanical Android onboarding exports; all setup decisions remain in storage.
|
||||||
|
use crate::{
|
||||||
|
MobileOnboardingDiscovery, MobileOnboardingFfiError, MobileOnboardingOutcome,
|
||||||
|
MobileOnboardingProgress, MobilePrivateRuntime, MobileSecretBackend,
|
||||||
|
platform::SecretBackendAdapter,
|
||||||
|
};
|
||||||
|
use ironstorage::{
|
||||||
|
mobile_onboarding::{
|
||||||
|
MobileOnboardingRequest,
|
||||||
|
android::{AndroidOnboarding, AndroidOnboardingStatus},
|
||||||
|
},
|
||||||
|
repository::SecretBytes,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileAndroidSetupKey {
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub user_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileAndroidSetupStatus {
|
||||||
|
pub configured: bool,
|
||||||
|
pub connected: bool,
|
||||||
|
pub existing_clone: bool,
|
||||||
|
pub keys: Vec<MobileAndroidSetupKey>,
|
||||||
|
pub selected_key: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
impl From<AndroidOnboardingStatus> for MobileAndroidSetupStatus {
|
||||||
|
fn from(status: AndroidOnboardingStatus) -> Self {
|
||||||
|
Self {
|
||||||
|
title: status.title,
|
||||||
|
detail: status.detail,
|
||||||
|
configured: status.configured,
|
||||||
|
connected: status.connected,
|
||||||
|
existing_clone: status.existing_clone,
|
||||||
|
selected_key: status.selected_key,
|
||||||
|
keys: status
|
||||||
|
.keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| MobileAndroidSetupKey {
|
||||||
|
fingerprint: key.fingerprint().as_str().into(),
|
||||||
|
user_ids: key.user_ids().to_vec(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct MobileAndroidOnboarding {
|
||||||
|
setup: Arc<AndroidOnboarding>,
|
||||||
|
}
|
||||||
|
impl MobileAndroidOnboarding {
|
||||||
|
pub(crate) fn new(
|
||||||
|
runtime: &MobilePrivateRuntime,
|
||||||
|
backend: Arc<dyn MobileSecretBackend>,
|
||||||
|
) -> Result<Arc<Self>, MobileOnboardingFfiError> {
|
||||||
|
Ok(Arc::new(Self {
|
||||||
|
setup: Arc::new(AndroidOnboarding::new(
|
||||||
|
&runtime.storage,
|
||||||
|
Arc::new(SecretBackendAdapter(backend)),
|
||||||
|
)?),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn secret(value: Option<String>) -> Option<SecretBytes> {
|
||||||
|
value.map(|value| SecretBytes::new(value.into_bytes()))
|
||||||
|
}
|
||||||
|
fn outcome(
|
||||||
|
value: ironstorage::mobile_onboarding::MobileOnboardingOutcome,
|
||||||
|
) -> MobileOnboardingOutcome {
|
||||||
|
MobileOnboardingOutcome {
|
||||||
|
title: value.title().into(),
|
||||||
|
detail: value.detail().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[uniffi::export]
|
||||||
|
impl MobileAndroidOnboarding {
|
||||||
|
pub fn status(&self) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
|
||||||
|
Ok(self.setup.status()?.into())
|
||||||
|
}
|
||||||
|
pub fn generate(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
passphrase: String,
|
||||||
|
) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
|
||||||
|
Ok(self
|
||||||
|
.setup
|
||||||
|
.generate(&user_id, SecretBytes::new(passphrase.into_bytes()))?
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
pub fn import_key(
|
||||||
|
&self,
|
||||||
|
armor: String,
|
||||||
|
passphrase: Option<String>,
|
||||||
|
) -> Result<MobileAndroidSetupStatus, MobileOnboardingFfiError> {
|
||||||
|
Ok(self
|
||||||
|
.setup
|
||||||
|
.import(SecretBytes::new(armor.into_bytes()), secret(passphrase))?
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
pub fn setup_local(
|
||||||
|
&self,
|
||||||
|
fingerprint: String,
|
||||||
|
passphrase: Option<String>,
|
||||||
|
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||||
|
Ok(outcome(
|
||||||
|
self.setup.setup_local(&fingerprint, secret(passphrase))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub fn remote(
|
||||||
|
&self,
|
||||||
|
server_url: String,
|
||||||
|
account: String,
|
||||||
|
repository_path: String,
|
||||||
|
application_token: String,
|
||||||
|
) -> Result<Arc<MobileAndroidRemoteOnboarding>, MobileOnboardingFfiError> {
|
||||||
|
Ok(Arc::new(MobileAndroidRemoteOnboarding {
|
||||||
|
setup: Arc::clone(&self.setup),
|
||||||
|
request: MobileOnboardingRequest::new(
|
||||||
|
server_url,
|
||||||
|
account,
|
||||||
|
repository_path,
|
||||||
|
application_token.into_bytes(),
|
||||||
|
)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
pub fn replace_token(
|
||||||
|
&self,
|
||||||
|
account: String,
|
||||||
|
application_token: String,
|
||||||
|
) -> Result<(), MobileOnboardingFfiError> {
|
||||||
|
Ok(self
|
||||||
|
.setup
|
||||||
|
.replace_token(account, SecretBytes::new(application_token.into_bytes()))?)
|
||||||
|
}
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.setup.cancel();
|
||||||
|
}
|
||||||
|
pub fn progress(&self) -> MobileOnboardingProgress {
|
||||||
|
let progress = self.setup.progress();
|
||||||
|
MobileOnboardingProgress {
|
||||||
|
phase: progress.phase().into(),
|
||||||
|
title: progress.title().into(),
|
||||||
|
detail: progress.detail().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct MobileAndroidRemoteOnboarding {
|
||||||
|
setup: Arc<AndroidOnboarding>,
|
||||||
|
request: MobileOnboardingRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl MobileAndroidRemoteOnboarding {
|
||||||
|
pub fn discover(&self) -> Result<MobileOnboardingDiscovery, MobileOnboardingFfiError> {
|
||||||
|
let value = self.setup.discover(&self.request)?;
|
||||||
|
Ok(MobileOnboardingDiscovery {
|
||||||
|
branches: value.branches().to_vec(),
|
||||||
|
selected_branch: value.selected_branch() as u32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn clone_store(
|
||||||
|
&self,
|
||||||
|
branch: String,
|
||||||
|
fingerprint: String,
|
||||||
|
passphrase: Option<String>,
|
||||||
|
use_existing: bool,
|
||||||
|
) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||||
|
Ok(outcome(self.setup.clone_store(
|
||||||
|
&self.request,
|
||||||
|
&branch,
|
||||||
|
&fingerprint,
|
||||||
|
secret(passphrase),
|
||||||
|
use_existing,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
pub fn connect(&self) -> Result<MobileOnboardingOutcome, MobileOnboardingFfiError> {
|
||||||
|
Ok(outcome(self.setup.connect(&self.request)?))
|
||||||
|
}
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.setup.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ uniffi::setup_scaffolding!();
|
|||||||
|
|
||||||
mod android_keystore;
|
mod android_keystore;
|
||||||
pub use android_keystore::*;
|
pub use android_keystore::*;
|
||||||
|
mod android_onboarding;
|
||||||
|
pub use android_onboarding::*;
|
||||||
mod platform;
|
mod platform;
|
||||||
pub use platform::*;
|
pub use platform::*;
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,16 @@ pub struct MobilePrivateStorageLayout {
|
|||||||
|
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
pub struct MobilePrivateRuntime {
|
pub struct MobilePrivateRuntime {
|
||||||
storage: PrivateStorage,
|
pub(crate) storage: PrivateStorage,
|
||||||
}
|
}
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
impl MobilePrivateRuntime {
|
impl MobilePrivateRuntime {
|
||||||
|
pub fn android_onboarding(
|
||||||
|
&self,
|
||||||
|
backend: Arc<dyn MobileSecretBackend>,
|
||||||
|
) -> Result<Arc<crate::MobileAndroidOnboarding>, crate::MobileOnboardingFfiError> {
|
||||||
|
crate::MobileAndroidOnboarding::new(self, backend)
|
||||||
|
}
|
||||||
pub fn android_secret_backend(
|
pub fn android_secret_backend(
|
||||||
&self,
|
&self,
|
||||||
keystore: Arc<dyn crate::MobileAndroidKeystore>,
|
keystore: Arc<dyn crate::MobileAndroidKeystore>,
|
||||||
|
|||||||
@@ -266,6 +266,31 @@ impl KeyStore {
|
|||||||
Ok(store)
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge prepared key material while retaining existing private keys.
|
||||||
|
pub(crate) fn extend_from_path(&mut self, path: &Path) -> Result<(), CryptoError> {
|
||||||
|
let loaded = match Self::load(path) {
|
||||||
|
Ok(loaded) => loaded,
|
||||||
|
Err(CryptoError::NoKeyMaterial) => return Ok(()),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
if self.keys.len() + loaded.keys.len() > MAX_IMPORTED_KEYS {
|
||||||
|
return Err(CryptoError::TooManyKeys);
|
||||||
|
}
|
||||||
|
for (fingerprint, material) in loaded.keys {
|
||||||
|
if let Some(existing) = self.keys.get_mut(&fingerprint) {
|
||||||
|
if existing.public.primary_key != material.public.primary_key {
|
||||||
|
return Err(CryptoError::DuplicateFingerprint { fingerprint });
|
||||||
|
}
|
||||||
|
if existing.secret.is_none() {
|
||||||
|
existing.secret = material.secret;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.keys.insert(fingerprint, material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Transactionally import one armored or binary stream containing public and/or secret keys.
|
/// Transactionally import one armored or binary stream containing public and/or secret keys.
|
||||||
pub fn import(&mut self, bytes: &[u8]) -> Result<Vec<KeyInfo>, CryptoError> {
|
pub fn import(&mut self, bytes: &[u8]) -> Result<Vec<KeyInfo>, CryptoError> {
|
||||||
if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
|
if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
|
||||||
|
|||||||
@@ -1466,6 +1466,50 @@ impl GitRepository {
|
|||||||
credentials: &impl GitRemoteCredentialProvider,
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
transport: &impl GitFetchTransport,
|
transport: &impl GitFetchTransport,
|
||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
|
) -> Result<Vec<String>, GitError> {
|
||||||
|
let branches = Self::probe_remote_with_transport(
|
||||||
|
parent,
|
||||||
|
identity,
|
||||||
|
configured,
|
||||||
|
credentials,
|
||||||
|
transport,
|
||||||
|
control,
|
||||||
|
)?;
|
||||||
|
if branches.is_empty() {
|
||||||
|
return Err(GitError::InvalidRepository(
|
||||||
|
"the remote has no branches".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate HTTPS authentication before saving onboarding state, including an empty remote.
|
||||||
|
pub(crate) fn validate_remote_with_transport(
|
||||||
|
parent: &Path,
|
||||||
|
identity: GitIdentity,
|
||||||
|
configured: &GitRemote,
|
||||||
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
|
transport: &impl GitFetchTransport,
|
||||||
|
control: &GitOperationControl,
|
||||||
|
) -> Result<(), GitError> {
|
||||||
|
Self::probe_remote_with_transport(
|
||||||
|
parent,
|
||||||
|
identity,
|
||||||
|
configured,
|
||||||
|
credentials,
|
||||||
|
transport,
|
||||||
|
control,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_remote_with_transport(
|
||||||
|
parent: &Path,
|
||||||
|
identity: GitIdentity,
|
||||||
|
configured: &GitRemote,
|
||||||
|
credentials: &impl GitRemoteCredentialProvider,
|
||||||
|
transport: &impl GitFetchTransport,
|
||||||
|
control: &GitOperationControl,
|
||||||
) -> Result<Vec<String>, GitError> {
|
) -> Result<Vec<String>, GitError> {
|
||||||
control.report(GitProgressPhase::Validating)?;
|
control.report(GitProgressPhase::Validating)?;
|
||||||
ensure_remote_transport_available(configured.endpoint())?;
|
ensure_remote_transport_available(configured.endpoint())?;
|
||||||
@@ -1648,11 +1692,6 @@ impl GitRepository {
|
|||||||
.collect::<Result<Vec<_>, GitError>>()?;
|
.collect::<Result<Vec<_>, GitError>>()?;
|
||||||
branches.sort();
|
branches.sort();
|
||||||
branches.dedup();
|
branches.dedup();
|
||||||
if branches.is_empty() {
|
|
||||||
return Err(GitError::InvalidRepository(
|
|
||||||
"the remote has no branches".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(branches)
|
Ok(branches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Storage-owned first-run setup for the native mobile applications.
|
//! Storage-owned first-run setup for the native mobile applications.
|
||||||
|
|
||||||
|
pub mod android;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
fmt, fs,
|
fmt, fs,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -532,7 +532,7 @@ fn regular_bytes(
|
|||||||
}
|
}
|
||||||
Ok(Some(bytes))
|
Ok(Some(bytes))
|
||||||
}
|
}
|
||||||
fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, PrivateStorageError> {
|
pub(crate) fn exclusive_lock(parent: &Dir, name: &str) -> Result<fs::File, PrivateStorageError> {
|
||||||
if let Ok(metadata) = parent.symlink_metadata(name)
|
if let Ok(metadata) = parent.symlink_metadata(name)
|
||||||
&& (metadata.file_type().is_symlink() || !metadata.is_file())
|
&& (metadata.file_type().is_symlink() || !metadata.is_file())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1010,6 +1010,32 @@ struct SecretRecord {
|
|||||||
fn encode_record(
|
fn encode_record(
|
||||||
reference: &SecretReference,
|
reference: &SecretReference,
|
||||||
value: &SecretBytes,
|
value: &SecretBytes,
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
encode_record_with_owner(reference, value, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unique protected owner distinguishes newly created credentials during crash rollback.
|
||||||
|
pub(crate) fn encode_onboarding_record(
|
||||||
|
reference: &SecretReference,
|
||||||
|
value: &SecretBytes,
|
||||||
|
owner: &[u8],
|
||||||
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
if owner.is_empty()
|
||||||
|
|| owner.len() > 64
|
||||||
|
|| !matches!(
|
||||||
|
reference.kind,
|
||||||
|
SecretReferenceKind::HttpsGitCredential { .. }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
encode_record_with_owner(reference, value, Some(owner))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_record_with_owner(
|
||||||
|
reference: &SecretReference,
|
||||||
|
value: &SecretBytes,
|
||||||
|
owner: Option<&[u8]>,
|
||||||
) -> Result<SecretBytes, SecretStoreError> {
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
validate_secret(value)?;
|
validate_secret(value)?;
|
||||||
let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024);
|
let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024);
|
||||||
@@ -1025,7 +1051,10 @@ fn encode_record(
|
|||||||
application_id,
|
application_id,
|
||||||
account,
|
account,
|
||||||
} => {
|
} => {
|
||||||
encoded.push(2);
|
encoded.push(if owner.is_some() { 4 } else { 2 });
|
||||||
|
if let Some(owner) = owner {
|
||||||
|
write_field(&mut encoded, owner)?;
|
||||||
|
}
|
||||||
write_field(&mut encoded, server_id.as_bytes())?;
|
write_field(&mut encoded, server_id.as_bytes())?;
|
||||||
write_field(&mut encoded, application_id.as_bytes())?;
|
write_field(&mut encoded, application_id.as_bytes())?;
|
||||||
write_field(&mut encoded, account.as_bytes())?;
|
write_field(&mut encoded, account.as_bytes())?;
|
||||||
@@ -1063,7 +1092,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
|
|||||||
SecretReference::openpgp_passphrase(read_text(fingerprint)?)
|
SecretReference::openpgp_passphrase(read_text(fingerprint)?)
|
||||||
.map_err(|_| SecretStoreError::Corrupted)?
|
.map_err(|_| SecretStoreError::Corrupted)?
|
||||||
}
|
}
|
||||||
2 => {
|
2 | 4 => {
|
||||||
|
if kind == 4 {
|
||||||
|
let (owner, rest) = read_field(remainder)?;
|
||||||
|
if owner.is_empty() || owner.len() > 64 {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
remainder = rest;
|
||||||
|
}
|
||||||
let (server_id, rest) = read_field(remainder)?;
|
let (server_id, rest) = read_field(remainder)?;
|
||||||
let (application_id, rest) = read_field(rest)?;
|
let (application_id, rest) = read_field(rest)?;
|
||||||
let (account, rest) = read_field(rest)?;
|
let (account, rest) = read_field(rest)?;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Android onboarding (#95)
|
||||||
|
|
||||||
|
Compose collects inputs and renders the typed Rust setup state. `NativePrivateSession`
|
||||||
|
shares one private runtime and Android Keystore callback between the shell and setup.
|
||||||
|
Blocking calls and progress polling run on IO workers. Internet access is allowed;
|
||||||
|
cleartext connections remain disabled. The storage API rejects every non-HTTPS
|
||||||
|
onboarding endpoint and credential-bearing URL before opening transport.
|
||||||
|
|
||||||
|
Native loading initializes Rust's Android platform certificate verifier with the
|
||||||
|
application Context before HTTPS can run. Its official JVM support comes from the
|
||||||
|
checksum-pinned Cargo component, is verified and locked by Gradle, and is retained
|
||||||
|
by Release shrinking. Certificate validation uses the operating system trust store.
|
||||||
|
|
||||||
|
Local setup generates or imports a private GPG key, verifies its passphrase, creates
|
||||||
|
Git and the upstream `.gpg-id`, and commits the configuration. No server, camera,
|
||||||
|
or biometric permission is needed. Encrypted prepared keys survive process death;
|
||||||
|
passphrases and tokens remain in memory only. Form secrets survive rotation through
|
||||||
|
a ViewModel and are excluded from saved-state Bundles. After process death transport
|
||||||
|
and passphrase inputs must be entered again.
|
||||||
|
|
||||||
|
HTTPS setup discovers branches with a temporary Rust repository, selects the branch,
|
||||||
|
and checks the selected private key against the cloned root recipient policy. Reusing
|
||||||
|
an existing clone requires the same authoritative origin and current branch and
|
||||||
|
preserves its contents. Adding a remote to a local store validates network/TLS/token
|
||||||
|
access before saving anything, and accepts an authenticated empty remote.
|
||||||
|
|
||||||
|
Setup serializes against the private runtime lock. Its private transaction journal
|
||||||
|
contains only paths, credential identities and an ownership identifier. Replacement
|
||||||
|
vault/key directories are synced before installation; the configuration is the commit
|
||||||
|
point. Recovery restores previous directories and removes only a newly created origin
|
||||||
|
and a credential matching the protected ownership record. Missing or corrupt journals
|
||||||
|
with backups retain those backups and report recovery. Cancellation before the commit
|
||||||
|
boundary discards staging; once installation starts, the transaction finishes or rolls
|
||||||
|
back. Existing data and credentials are never replaced by initial setup.
|
||||||
|
|
||||||
|
Tokens and the temporary credential ownership record use the injected Android
|
||||||
|
Keystore backend. Explicit token replacement preserves the configured identities and
|
||||||
|
never returns the previous token to Kotlin. Neither Git URLs nor configuration files
|
||||||
|
contain credentials. No live repository is opened through SAF and no application
|
||||||
|
component launches a subprocess.
|
||||||
|
|
||||||
|
Rust regression fixtures cover pass-compatible encrypted entries, process restart,
|
||||||
|
authentication/TLS/network/cancellation failures, mismatched recipients, existing
|
||||||
|
clones/remotes/tokens, protected-store failure, interrupted installation, credential
|
||||||
|
ownership and low-storage recovery. Emulator tests exercise the generated native API
|
||||||
|
and staged Compose forms; runtime and packaging checks use the three documented ARM
|
||||||
|
emulator configurations. Real private-server access still depends on supplied account
|
||||||
|
credentials; synthetic fixtures establish failure and rollback contracts independently.
|
||||||
|
|
||||||
|
## Validation (2026-10-09)
|
||||||
|
|
||||||
|
The required Rust formatting, warnings-as-errors workspace check, Clippy and all
|
||||||
|
workspace tests passed (459 tests). Android verification passed lint, JVM tests,
|
||||||
|
Kotlin formatting, module boundaries, both ARM ABI builds, Debug/Release APK and
|
||||||
|
Release AAB audits, 16 KB alignment, UniFFI parity, backup/manifest checks and
|
||||||
|
dependency/license inventories. Instrumentation and five cold starts passed on
|
||||||
|
ARM64 API 26/4 KB, API 37/4 KB and API 37/strict 16 KB emulators. ARMv7 was
|
||||||
|
built and audited; a compatible ARMv7 emulator image was not available.
|
||||||
|
|
||||||
|
Computer use verified blank generation rejection, manual import availability,
|
||||||
|
key selection and masked passphrase retention through rotation, successful
|
||||||
|
encrypted local-store creation, and the optional HTTPS action in Preferences.
|
||||||
|
The user entered the disposable GPG passphrase directly into the emulator.
|
||||||
|
No private-server credential was requested or used for this validation; native
|
||||||
|
transport fixtures cover clone success, token replacement and rollback boundaries.
|
||||||
|
An opt-in native integration test also fetched branches from the public
|
||||||
|
`octocat/Hello-World` HTTPS repository, connected an isolated local store and replaced
|
||||||
|
its synthetic token on all three emulators. Run that test with instrumentation
|
||||||
|
argument `ironstorage_https_integration=true`; routine CI remains network independent.
|
||||||
@@ -59,6 +59,7 @@ fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
|
|||||||
"android.permission.USE_BIOMETRIC",
|
"android.permission.USE_BIOMETRIC",
|
||||||
"android.permission.USE_FINGERPRINT",
|
"android.permission.USE_FINGERPRINT",
|
||||||
"de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
|
"de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
|
||||||
|
"android.permission.INTERNET",
|
||||||
];
|
];
|
||||||
for node in root.children().filter(|node| node.is_element()) {
|
for node in root.children().filter(|node| node.is_element()) {
|
||||||
if node.tag_name().name().starts_with("uses-permission")
|
if node.tag_name().name().starts_with("uses-permission")
|
||||||
@@ -440,6 +441,13 @@ mod tests {
|
|||||||
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
|
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
|
||||||
);
|
);
|
||||||
assert!(manifest_text(&source).is_ok());
|
assert!(manifest_text(&source).is_ok());
|
||||||
|
assert!(
|
||||||
|
manifest_text(&source.replace(
|
||||||
|
"<application",
|
||||||
|
"<uses-permission android:name=\"android.permission.INTERNET\"/><application"
|
||||||
|
))
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
for bad in [
|
for bad in [
|
||||||
source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
|
source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
|
||||||
source.replace(
|
source.replace(
|
||||||
@@ -448,7 +456,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
source.replace(
|
source.replace(
|
||||||
"<application",
|
"<application",
|
||||||
"<uses-permission android:name=\"android.permission.INTERNET\"/><application",
|
"<uses-permission android:name=\"android.permission.CAMERA\"/><application",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
assert!(manifest_text(&bad).is_err());
|
assert!(manifest_text(&bad).is_err());
|
||||||
|
|||||||
Reference in new issue
Block a user