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