From 5c933a99a3888b3e8dde96314c55804157a1f421 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 9 Oct 2026 14:45:57 +0200 Subject: [PATCH] Implement Android Home activity and HTTPS synchronization (#97) --- android/README.md | 5 +- .../de/rfc1437/ironstorage/HomeScreenTest.kt | 188 ++++++++ .../de/rfc1437/ironstorage/ShellModule.kt | 14 + .../ironstorage/core/HomeRepository.kt | 66 +++ .../ironstorage/core/NativeHomeRepository.kt | 203 +++++++++ .../ironstorage/features/HomeScreen.kt | 281 ++++++++++++ .../ironstorage/features/ShellScreen.kt | 11 +- .../ironstorage/features/ShellViewModel.kt | 2 + .../src/main/res/drawable/ic_branch.xml | 3 + .../src/main/res/drawable/ic_file.xml | 3 + .../src/main/res/drawable/ic_history.xml | 3 + .../src/main/res/drawable/ic_incoming.xml | 3 + .../src/main/res/drawable/ic_outgoing.xml | 3 + .../src/main/res/drawable/ic_protected.xml | 3 + .../src/main/res/drawable/ic_refresh.xml | 3 + .../src/main/res/drawable/ic_success.xml | 3 + .../src/main/res/drawable/ic_warning.xml | 3 + .../features/src/main/res/values/strings.xml | 19 + .../bridge/AndroidOnboardingTest.kt | 88 ++++ crates/mobile-bridge/src/lib.rs | 19 + crates/mobile-bridge/src/private_storage.rs | 47 +- crates/storage/src/mobile_authentication.rs | 35 +- crates/storage/src/mobile_home.rs | 410 ++++++++++++++---- .../storage/src/mobile_home/android_tests.rs | 370 ++++++++++++++++ .../storage/src/mobile_onboarding/android.rs | 197 ++++++++- crates/storage/src/mobile_private_storage.rs | 40 +- crates/storage/src/mobile_totp.rs | 6 +- crates/storage/tests/mobile_totp.rs | 19 + docs/android/home-sync.md | 71 +++ 29 files changed, 2008 insertions(+), 110 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/HomeRepository.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeHomeRepository.kt create mode 100644 android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt create mode 100644 android/features/src/main/res/drawable/ic_branch.xml create mode 100644 android/features/src/main/res/drawable/ic_file.xml create mode 100644 android/features/src/main/res/drawable/ic_history.xml create mode 100644 android/features/src/main/res/drawable/ic_incoming.xml create mode 100644 android/features/src/main/res/drawable/ic_outgoing.xml create mode 100644 android/features/src/main/res/drawable/ic_protected.xml create mode 100644 android/features/src/main/res/drawable/ic_refresh.xml create mode 100644 android/features/src/main/res/drawable/ic_success.xml create mode 100644 android/features/src/main/res/drawable/ic_warning.xml create mode 100644 crates/storage/src/mobile_home/android_tests.rs create mode 100644 docs/android/home-sync.md diff --git a/android/README.md b/android/README.md index 7259dc9..df416d5 100644 --- a/android/README.md +++ b/android/README.md @@ -1,12 +1,13 @@ # Native Android build Architecture and issue ownership: [ADR](../docs/android/architecture.md) and -[parity matrix](../docs/android/parity.md). The native foundation, onboarding and navigation implement #89–#96, including private storage, Keystore +[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation and Home implement #89–#97, including private storage, Keystore callbacks and build gates. Both variants invoke Rust through the generated UniFFI API. Local-first and HTTPS onboarding is described in [onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned, revision-checked [adaptive navigation](../docs/android/password-navigation.md); -remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the +Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md). +Remaining feature screens retain 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 diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt new file mode 100644 index 0000000..0b0b581 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt @@ -0,0 +1,188 @@ +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.assertIsNotEnabled +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import de.rfc1437.ironstorage.core.HomeAction +import de.rfc1437.ironstorage.core.HomeActionKind +import de.rfc1437.ironstorage.core.HomeChange +import de.rfc1437.ironstorage.core.HomeCommit +import de.rfc1437.ironstorage.core.HomeFailure +import de.rfc1437.ironstorage.core.HomeFailureKind +import de.rfc1437.ironstorage.core.HomeFreshness +import de.rfc1437.ironstorage.core.HomeNotice +import de.rfc1437.ironstorage.core.HomePage +import de.rfc1437.ironstorage.core.HomeRepository +import de.rfc1437.ironstorage.core.HomeRow +import de.rfc1437.ironstorage.core.HomeState +import de.rfc1437.ironstorage.core.ShellIcon +import de.rfc1437.ironstorage.features.HomeScreen +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class HomeScreenTest { + @get:Rule val compose = createAndroidComposeRule() + private fun show(repository: Fixture) { + compose.activity.runOnUiThread { + compose.activity.setContent { MaterialTheme { HomeScreen(repository) } } + } + } + + @Test fun typedActionsUseTheConfiguredIdentityAndNeverReplayAfterReload() { + val repository = Fixture() + show(repository) + compose.onNodeWithText("Cached activity").assertIsDisplayed() + compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick() + compose.onNodeWithText("Android fixture").assertIsDisplayed() + compose.onNodeWithText("fixture@example.test").assertIsDisplayed() + compose.onNodeWithText("Commit message").performTextInput("Commit encrypted objects") + compose.onNodeWithText("Create commit").performClick() + assertEquals( + HomeActionKind.Commit to "Commit encrypted objects", + repository.requests.single() + ) + compose.onNodeWithText("Cancel").performClick() + for (action in listOf(HomeActionKind.Fetch, HomeActionKind.Pull, HomeActionKind.Push)) { + compose.onNodeWithText(action.name).performScrollTo().performClick() + assertEquals(action, repository.requests.last().first) + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithText("Reload status").performClick() + } + val before = repository.requests.size + show(repository) + compose.waitForIdle() + assertEquals(before, repository.requests.size) + assertTrue(repository.entries > 0) + } + + @Test fun slowOperationsLeaveScrollingAndCancellationResponsiveWithLargeHistory() { + val repository = Fixture(1000) + show(repository) + compose.onNodeWithText("Fetch").performScrollTo().performClick() + compose.onNodeWithText("Receiving remote objects").assertIsDisplayed() + compose.onNodeWithText("Fetch").performScrollTo().assertIsNotEnabled() + compose.onNode(hasScrollAction()).performScrollToNode(hasText("object-999")) + compose.onNodeWithText("object-999").assertIsDisplayed() + compose.onNodeWithText("Cancel").performClick() + compose.onNode(hasScrollAction()).performScrollToNode(hasText("Refresh Interrupted")) + compose.onNodeWithText("Refresh Interrupted").assertIsDisplayed() + assertEquals(1, repository.cancellations) + } + + @Test fun swipeRefreshRequestsTheRustRefreshWorkflow() { + val repository = Fixture() + show(repository) + compose.onRoot().performTouchInput { swipeDown() } + compose.waitUntil(5000) { repository.refreshes == 1 } + assertTrue(repository.requests.isEmpty()) + compose.onNodeWithText("Cancel").performClick() + } + private class Fixture(changes: Int = 1) : HomeRepository { + private val actions = HomeActionKind.entries.map { + HomeAction( + it, + it.name, + when (it) { + HomeActionKind.Commit -> ShellIcon.Success + HomeActionKind.Fetch -> ShellIcon.Refresh + HomeActionKind.Pull -> ShellIcon.Incoming + HomeActionKind.Push -> ShellIcon.Outgoing + } + ) + } + override val state = MutableStateFlow( + HomeState( + page = HomePage( + HomeFreshness.Cached, + 1234567890, + listOf( + HomeRow( + "tracked", + "origin/main", + "Tracked HTTPS branch", + ShellIcon.Branch, + actions + ) + ), + listOf( + HomeCommit( + "incoming-id", + "Incoming encrypted objects", + "Fixture author", + 1234567890, + (0 until changes).map { + HomeChange( + "change-$it", + "object-$it", + "Added password entry", + ShellIcon.Key + ) + }, + emptyList() + ) + ), + emptyList(), + 1u, + 0u, + null + ), + name = "Android fixture", + email = "fixture@example.test" + ) + ) + val requests = mutableListOf>() + var cancellations = 0 + var entries = 0 + var refreshes = 0 + override fun enter() { + entries++ + } + override fun refresh() { + refreshes++ + busy() + } + override fun perform(action: HomeActionKind, message: String) { + requests += action to message + busy() + } + private fun busy() { + state.value = state.value.copy( + busy = true, + failure = null, + progress = HomeNotice("Receiving remote objects", "Slow fixture network") + ) + } + override fun reload() { + state.value = state.value.copy(failure = null) + } + override fun cancel() { + cancellations++ + state.value = state.value.copy( + busy = false, + progress = null, + failure = HomeFailure( + HomeFailureKind.Interrupted, + "Refresh Interrupted", + "Reload cached activity before retrying." + ) + ) + } + override fun leave() = Unit + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index b226e28..bced4a5 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -6,7 +6,9 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.NativeFixtureShellRepository +import de.rfc1437.ironstorage.core.NativeHomeRepository import de.rfc1437.ironstorage.core.NativeOnboardingRepository import de.rfc1437.ironstorage.core.NativePasswordRepository import de.rfc1437.ironstorage.core.NativePrivateSession @@ -23,6 +25,18 @@ import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) object ShellModule { + @Provides + @Singleton + fun homeRepository( + session: NativePrivateSession, + passwords: PasswordRepository + ): HomeRepository = NativeHomeRepository( + session, + passwords, + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + Dispatchers.IO.limitedParallelism(2) + ) + @Provides @Singleton fun passwordRepository(session: NativePrivateSession): PasswordRepository = diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/HomeRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/HomeRepository.kt new file mode 100644 index 0000000..959467b --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/HomeRepository.kt @@ -0,0 +1,66 @@ +package de.rfc1437.ironstorage.core + +import kotlinx.coroutines.flow.StateFlow + +enum class HomeActionKind { Commit, Fetch, Pull, Push } +enum class HomeFreshness { NeverRefreshed, Cached, Current } +enum class HomeFailureKind { + MissingConfiguration, + Configuration, + Authentication, + Conflict, + DirtyLocalChanges, + NoChanges, + Offline, + Interrupted, + SecureStorage, + Repository, + PartialProgress +} +data class HomeAction(val kind: HomeActionKind, val title: String, val icon: ShellIcon) +data class HomeRow( + val id: String, + val title: String, + val detail: String, + val icon: ShellIcon, + val actions: List +) +data class HomeChange(val id: String, val title: String, val detail: String, val icon: ShellIcon) +data class HomeCommit( + val id: String, + val title: String, + val detail: String, + val timestamp: Long, + val changes: List, + val actions: List +) +data class HomeNotice(val title: String, val detail: String) +data class HomePage( + val freshness: HomeFreshness, + val refreshedAt: Long?, + val summaries: List, + val incoming: List, + val outgoing: List, + val incomingTotal: UInt, + val outgoingTotal: UInt, + val notice: HomeNotice?, + val activityKnown: Boolean = true +) +data class HomeFailure(val kind: HomeFailureKind, val title: String, val detail: String) +data class HomeState( + val page: HomePage? = null, + val name: String = "", + val email: String = "", + val busy: Boolean = false, + val progress: HomeNotice? = null, + val failure: HomeFailure? = null +) +interface HomeRepository { + val state: StateFlow + fun enter() + fun refresh() + fun perform(action: HomeActionKind, message: String = "") + fun reload() + fun cancel() + fun leave() +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeHomeRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeHomeRepository.kt new file mode 100644 index 0000000..ce74bc9 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeHomeRepository.kt @@ -0,0 +1,203 @@ +package de.rfc1437.ironstorage.core + +import android.os.SystemClock +import de.rfc1437.ironstorage.bridge.NativeBridgeException +import de.rfc1437.ironstorage.bridge.generated.MobileClock +import de.rfc1437.ironstorage.bridge.generated.MobileHomeActionKind +import de.rfc1437.ironstorage.bridge.generated.MobileHomeErrorKind +import de.rfc1437.ironstorage.bridge.generated.MobileHomeFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileHomeFreshness +import de.rfc1437.ironstorage.bridge.generated.MobileHomeOperation +import de.rfc1437.ironstorage.bridge.generated.MobileHomePage +import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class NativeHomeRepository( + private val session: NativePrivateSession, + private val passwords: PasswordRepository, + private val scope: CoroutineScope, + private val io: CoroutineDispatcher +) : HomeRepository { + private val mutable = MutableStateFlow(HomeState()) + override val state: StateFlow = mutable + private val sequence = AtomicLong() + private val mutex = Mutex() + + @Volatile private var active: MobileHomeOperation? = null + private var cancelTicket: Long? = null + private val clock = object : MobileClock { + override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong() + } + override fun enter() = run(Request.Enter) + override fun reload() = run(Request.Enter) + override fun refresh() = run(Request.Refresh) + override fun perform(action: HomeActionKind, message: String) = run( + when (action) { + HomeActionKind.Commit -> Request.Commit + HomeActionKind.Fetch -> Request.Fetch + HomeActionKind.Pull -> Request.Pull + HomeActionKind.Push -> Request.Push + }, + message + ) + override fun cancel() { + cancelTicket = sequence.get() + val operation = active + scope.launch(io) { operation?.cancel() } + } + override fun leave() { + sequence.incrementAndGet() + val operation = active + scope.launch(io) { operation?.cancel() } + mutable.value = mutable.value.copy(busy = false, progress = null) + } + private enum class Request { Enter, Refresh, Commit, Fetch, Pull, Push } + private fun run(request: Request, message: String = "") { + if (mutable.value.busy) return + val ticket = sequence.incrementAndGet() + mutable.value = mutable.value.copy(busy = true, failure = null, progress = null) + scope.launch { + mutex.withLock { + if (sequence.get() != ticket) return@withLock + val polling = launch { + while (sequence.get() == ticket) { + delay(150) + val progress = withContext(io) { active?.progress() } + if (sequence.get() == ticket && progress != null) { + mutable.value = mutable.value.copy( + progress = HomeNotice(progress.title, progress.detail) + ) + } + } + } + try { + val current = withContext(io) { + session.storage().androidHomeOperation(session.backend(), clock) + } + active = current + if (sequence.get() != ticket || cancelTicket == ticket) { + withContext(io) { current.cancel() } + } + val identity = withContext(io) { current.identity() } + val cached = withContext(io) { + current.cached().toFacade(current.activityKnown()) + } + if (sequence.get() == ticket) { + mutable.value = mutable.value.copy( + page = cached, + name = identity.name, + email = identity.email + ) + } + val page = withContext(io) { + when (request) { + Request.Enter -> current.refreshIfStale() + Request.Refresh -> current.refreshGesture() + Request.Commit -> current.commit(message) + Request.Fetch -> current.fetch() + Request.Pull -> current.pull() + Request.Push -> current.push() + }.toFacade(current.activityKnown()) + } + if (sequence.get() == ticket) { + mutable.value = HomeState(page, identity.name, identity.email) + if (request == Request.Pull || request == Request.Refresh) { + passwords.refresh() + } + } + } catch (error: MobileHomeFfiException.Failed) { + if (sequence.get() == ticket) { + mutable.value = mutable.value.copy( + failure = HomeFailure(error.kind.toFacade(), error.title, error.detail) + ) + } + } catch (_: MobilePrivateStorageFfiException) { + failure( + ticket, + "Unlock the device or recover private storage, then reload Home." + ) + } catch (_: NativeBridgeException) { + failure(ticket, "Update the app and reload Home.") + } catch (_: java.io.IOException) { + failure( + ticket, + "Private storage is unavailable. Unlock the device and reload Home." + ) + } finally { + active = null + polling.cancelAndJoin() + if (sequence.get() == ticket) { + mutable.value = mutable.value.copy(busy = false, progress = null) + } + } + } + } + } + private fun failure(ticket: Long, detail: String) { + if (sequence.get() == ticket) { + mutable.value = mutable.value.copy( + failure = HomeFailure(HomeFailureKind.Repository, "Home is unavailable", detail) + ) + } + } +} + +private fun MobileHomeActionKind.toFacade(): HomeActionKind = when (this) { + MobileHomeActionKind.COMMIT -> HomeActionKind.Commit + MobileHomeActionKind.FETCH -> HomeActionKind.Fetch + MobileHomeActionKind.PULL -> HomeActionKind.Pull + MobileHomeActionKind.PUSH -> HomeActionKind.Push +} +private fun MobileHomeErrorKind.toFacade(): HomeFailureKind = when (this) { + MobileHomeErrorKind.MISSING_CONFIGURATION -> HomeFailureKind.MissingConfiguration + MobileHomeErrorKind.CONFIGURATION -> HomeFailureKind.Configuration + MobileHomeErrorKind.AUTHENTICATION -> HomeFailureKind.Authentication + MobileHomeErrorKind.CONFLICT -> HomeFailureKind.Conflict + MobileHomeErrorKind.DIRTY_LOCAL_CHANGES -> HomeFailureKind.DirtyLocalChanges + MobileHomeErrorKind.NO_CHANGES -> HomeFailureKind.NoChanges + MobileHomeErrorKind.OFFLINE -> HomeFailureKind.Offline + MobileHomeErrorKind.INTERRUPTED -> HomeFailureKind.Interrupted + MobileHomeErrorKind.SECURE_STORAGE -> HomeFailureKind.SecureStorage + MobileHomeErrorKind.REPOSITORY -> HomeFailureKind.Repository + MobileHomeErrorKind.PARTIAL_PROGRESS -> HomeFailureKind.PartialProgress +} +private fun MobileHomePage.toFacade(activityKnown: Boolean): HomePage { + fun action(value: de.rfc1437.ironstorage.bridge.generated.MobileHomeAction): HomeAction = + HomeAction(value.kind.toFacade(), value.title, value.icon.toFacade()) + fun commit(value: de.rfc1437.ironstorage.bridge.generated.MobileHomeCommit): HomeCommit = + HomeCommit( + value.id, + value.title, + value.detail, + value.timestamp, + value.changes.map { HomeChange(it.id, it.title, it.detail, it.icon.toFacade()) }, + value.actions.map(::action) + ) + return HomePage( + when (freshness) { + MobileHomeFreshness.NEVER_REFRESHED -> HomeFreshness.NeverRefreshed + MobileHomeFreshness.CACHED -> HomeFreshness.Cached + MobileHomeFreshness.CURRENT -> HomeFreshness.Current + }, + refreshedAt, + summaries.map { + HomeRow(it.id, it.title, it.detail, it.icon.toFacade(), it.actions.map(::action)) + }, + incoming.map(::commit), + outgoing.map(::commit), + incomingTotal, + outgoingTotal, + notice?.let { HomeNotice(it.title, it.detail) }, + activityKnown + ) +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt new file mode 100644 index 0000000..4d0c497 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt @@ -0,0 +1,281 @@ +package de.rfc1437.ironstorage.features + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.rfc1437.ironstorage.core.HomeAction +import de.rfc1437.ironstorage.core.HomeActionKind +import de.rfc1437.ironstorage.core.HomeFreshness +import de.rfc1437.ironstorage.core.HomeRepository +import de.rfc1437.ironstorage.core.ShellIcon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) { + val state by repository.state.collectAsStateWithLifecycle() + var committing by remember { mutableStateOf(false) } + var message by remember { mutableStateOf("") } + DisposableEffect(repository) { + repository.enter() + onDispose { repository.leave() } + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + committing = false + message = "" + repository.leave() + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.enter() } + fun perform(action: HomeActionKind) { + if (action == HomeActionKind.Commit) { + committing = true + } else { + repository.perform(action) + } + } + Column(modifier) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(enabled = !state.busy, onClick = repository::refresh) { + Text(stringResource(R.string.home_refresh)) + } + if (state.busy) { + TextButton(onClick = repository::cancel) { + Text(stringResource(R.string.home_cancel)) + } + Text( + state.progress?.title ?: stringResource(R.string.home_loading), + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } + ) + } + } + PullToRefreshBox( + isRefreshing = state.busy, + onRefresh = { if (!state.busy) repository.refresh() }, + modifier = Modifier.weight(1f) + ) { + LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + Text( + stringResource(R.string.home_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() } + ) + } + if (state.busy) { + item { + Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) { + CircularProgressIndicator() + state.progress?.let { + Text(it.detail) + } + } + } + } + state.failure?.let { failure -> + item { + Text(failure.title, color = MaterialTheme.colorScheme.error) + Text(failure.detail) + TextButton(enabled = !state.busy, onClick = repository::reload) { + Text(stringResource(R.string.home_reload)) + } + } + } + val page = state.page + if (page != null) { + item { + Text( + stringResource( + when (page.freshness) { + HomeFreshness.NeverRefreshed -> R.string.home_never + HomeFreshness.Cached -> R.string.home_cached + HomeFreshness.Current -> R.string.home_current + } + ) + ) + page.refreshedAt?.let { timestamp -> + val formatted = DateUtils.formatDateTime( + LocalContext.current, + timestamp * 1000, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME + ) + Text(stringResource(R.string.home_checked, formatted)) + } + page.notice?.let { + Text(it.title, style = MaterialTheme.typography.titleMedium) + Text(it.detail) + } + } + items(page.summaries, key = { it.id }) { row -> + Column { + ListItem( + headlineContent = { Text(row.title) }, + supportingContent = { Text(row.detail) }, + leadingContent = { HomeIcon(row.icon) } + ) + HomeActions(row.actions, state.busy, ::perform) + } + } + if (page.activityKnown) { + listOf( + Triple(R.string.home_incoming, page.incomingTotal, page.incoming), + Triple(R.string.home_outgoing, page.outgoingTotal, page.outgoing) + ).forEach { (label, total, commits) -> + item(key = "activity:$label") { + Text( + stringResource(label, total.toString()), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { heading() } + ) + if (total == 0u) Text(stringResource(R.string.home_no_activity)) + } + commits.forEach { commit -> + item(key = "$label:${commit.id}") { + Text(commit.title, style = MaterialTheme.typography.titleMedium) + Text(commit.detail) + val time = DateUtils.formatDateTime( + LocalContext.current, + commit.timestamp * 1000, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME + ) + Text(time) + HomeActions(commit.actions, state.busy, ::perform) + } + items( + commit.changes, + key = { "$label:${commit.id}:${it.id}" } + ) { change -> + ListItem( + headlineContent = { Text(change.title) }, + supportingContent = { Text(change.detail) }, + leadingContent = { HomeIcon(change.icon) } + ) + } + } + if (total > commits.size.toUInt()) { + item { + Text( + pluralStringResource( + R.plurals.home_bounded, + commits.size, + commits.size + ) + ) + } + } + } + } + } + } + } + } + if (committing) { + AlertDialog( + onDismissRequest = { + committing = false + message = "" + }, + title = { Text(stringResource(R.string.home_commit_title)) }, + text = { + Column { + Text(state.name) + Text(state.email) + OutlinedTextField( + value = message, + onValueChange = { message = it }, + label = { Text(stringResource(R.string.home_commit_message)) } + ) + } + }, + confirmButton = { + TextButton(enabled = !state.busy, onClick = { + val submitted = message + message = "" + committing = false + repository.perform(HomeActionKind.Commit, submitted) + }) { Text(stringResource(R.string.home_commit_submit)) } + }, + dismissButton = { + TextButton(onClick = { + committing = false + message = "" + }) { + Text(stringResource(R.string.home_cancel)) + } + } + ) + } +} + +@Composable +private fun HomeActions( + actions: List, + busy: Boolean, + perform: (HomeActionKind) -> Unit +) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + actions.forEach { action -> + TextButton(enabled = !busy, onClick = { perform(action.kind) }) { + HomeIcon(action.icon) + Text(action.title, Modifier.padding(start = 8.dp)) + } + } + } +} + +@Composable +private fun HomeIcon(icon: ShellIcon) { + Icon( + painterResource( + when (icon) { + ShellIcon.Key -> R.drawable.ic_key + ShellIcon.Warning -> R.drawable.ic_warning + ShellIcon.Incoming -> R.drawable.ic_incoming + ShellIcon.Outgoing -> R.drawable.ic_outgoing + ShellIcon.Success -> R.drawable.ic_success + ShellIcon.Refresh, ShellIcon.Sync -> R.drawable.ic_refresh + ShellIcon.Branch, ShellIcon.Merge -> R.drawable.ic_branch + ShellIcon.Protected -> R.drawable.ic_protected + ShellIcon.History -> R.drawable.ic_history + ShellIcon.Repository -> R.drawable.ic_folder + ShellIcon.Recipients -> R.drawable.ic_settings + ShellIcon.Signature, ShellIcon.File -> R.drawable.ic_file + else -> error("Invalid Rust Home icon") + } + ), + null + ) +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index 3d1a57c..55da6ca 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -53,10 +53,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) { modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - val browsing = !snapshot.fixture && snapshot.nativeFailure == null && - snapshot.storageRecovery == null && snapshot.state != ShellState.Empty && + val ready = !snapshot.fixture && snapshot.nativeFailure == null && + snapshot.storageRecovery == null && + (snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked) + val browsing = ready && snapshot.selectedTab == ShellTab.Passwords - if (!browsing) { + val home = ready && snapshot.selectedTab == ShellTab.Home + if (!browsing && !home) { Text( stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge @@ -92,6 +95,8 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) { } else if (browsing) { LaunchedEffect(viewModel) { viewModel.openPasswords() } PasswordScreen(viewModel.passwords, Modifier.weight(1f)) + } else if (home) { + HomeScreen(viewModel.home, Modifier.weight(1f)) } else { Text( title, diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index 55cd7dc..97df63b 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.ShellRepository @@ -16,6 +17,7 @@ class ShellViewModel @Inject constructor( private val repository: ShellRepository, val onboarding: OnboardingRepository, val passwords: PasswordRepository, + val home: HomeRepository, private val saved: SavedStateHandle ) : ViewModel() { val snapshot = repository.snapshot diff --git a/android/features/src/main/res/drawable/ic_branch.xml b/android/features/src/main/res/drawable/ic_branch.xml new file mode 100644 index 0000000..d96bb4f --- /dev/null +++ b/android/features/src/main/res/drawable/ic_branch.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_file.xml b/android/features/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000..70819e3 --- /dev/null +++ b/android/features/src/main/res/drawable/ic_file.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_history.xml b/android/features/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..9a7c67e --- /dev/null +++ b/android/features/src/main/res/drawable/ic_history.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_incoming.xml b/android/features/src/main/res/drawable/ic_incoming.xml new file mode 100644 index 0000000..0e40c46 --- /dev/null +++ b/android/features/src/main/res/drawable/ic_incoming.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_outgoing.xml b/android/features/src/main/res/drawable/ic_outgoing.xml new file mode 100644 index 0000000..0958be7 --- /dev/null +++ b/android/features/src/main/res/drawable/ic_outgoing.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_protected.xml b/android/features/src/main/res/drawable/ic_protected.xml new file mode 100644 index 0000000..c0f4e4f --- /dev/null +++ b/android/features/src/main/res/drawable/ic_protected.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_refresh.xml b/android/features/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..57d8520 --- /dev/null +++ b/android/features/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_success.xml b/android/features/src/main/res/drawable/ic_success.xml new file mode 100644 index 0000000..765d7b4 --- /dev/null +++ b/android/features/src/main/res/drawable/ic_success.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/drawable/ic_warning.xml b/android/features/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..22e94dd --- /dev/null +++ b/android/features/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 989e1f2..2cee15e 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -1,4 +1,23 @@ + Remote activity + Loading Home + Cancel + Refresh and sync + Reload status + Remote status has not been refreshed + Cached activity + Current activity + Last checked: %1$s + Incoming · %1$s + Outgoing · %1$s + No commits in this direction. + + Showing the latest %1$d commit. + Showing the latest %1$d commits. + + Commit password-store changes + Commit message + Create commit IronStorage No Password Store Your password store has not been configured. diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt index 07f163e..472e8ab 100644 --- a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/AndroidOnboardingTest.kt @@ -4,6 +4,9 @@ import androidx.test.platform.app.InstrumentationRegistry import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException import de.rfc1437.ironstorage.bridge.generated.MobileClock import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput +import de.rfc1437.ironstorage.bridge.generated.MobileHomeErrorKind +import de.rfc1437.ironstorage.bridge.generated.MobileHomeFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileHomeFreshness import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories @@ -21,6 +24,80 @@ import org.junit.Assume.assumeTrue import org.junit.Test class AndroidOnboardingTest { + @Test fun homeUsesThePrivateRuntimeAndSharesDirtyEditorReservations() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val id = "home-${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() + val clock = object : MobileClock { + override fun monotonicMilliseconds(): ULong = + android.os.SystemClock.elapsedRealtime().toULong() + } + NativeRuntime(context).privateStorage(directories).use { runtime -> + runtime.androidOnboarding(backend).use { setup -> + val key = requireNotNull( + setup.generate("Home fixture", "public-test-passphrase").selectedKey + ) + setup.setupLocal(key, "public-test-passphrase") + } + runtime.authentication(backend, clock).use { authentication -> + val draft = authentication.beginCreateEntry("", "draft", "public-test-passphrase") + runtime.androidHomeOperation(backend, clock).use { home -> + assertFalse(home.cached().remoteConfigured) + assertFalse(home.activityKnown()) + for (action in listOf({ home.pull() }, { home.push() })) { + val error = + assertThrows(MobileHomeFfiException.Failed::class.java) { action() } + assertEquals(MobileHomeErrorKind.DIRTY_LOCAL_CHANGES, error.kind) + } + assertTrue(authentication.entryEditor(draft.id).dirty) + } + authentication.saveEntryEditor( + draft.id, + listOf( + MobileEntryEditorInput(draft.page.fields[0].id, null, "fixture-password") + ) + ) + // Seed an uncommitted encrypted fixture; application code never reads the store. + val vault = File(runtime.layout().vault) + File(vault, "commit-fixture.gpg").writeBytes(File(vault, "draft.gpg").readBytes()) + runtime.androidHomeOperation(backend, clock).use { home -> + assertTrue(home.cached().summaries.any { it.id.startsWith("local:") }) + assertEquals( + "Changes Committed", + home.commit("Encrypted Home fixture").notice?.title + ) + } + authentication.manualLock() + runtime.androidHomeOperation(backend, clock).use { home -> + assertEquals(MobileHomeFreshness.CURRENT, home.refreshGesture().freshness) + assertTrue(home.identity().name.isNotEmpty()) + val error = assertThrows(MobileHomeFfiException.Failed::class.java) { + home.commit("No encrypted changes") + } + assertEquals(MobileHomeErrorKind.NO_CHANGES, error.kind) + for (action in listOf({ home.fetch() }, { home.pull() }, { home.push() })) { + val missing = + assertThrows(MobileHomeFfiException.Failed::class.java) { action() } + assertEquals(MobileHomeErrorKind.CONFIGURATION, missing.kind) + } + assertFalse(authentication.state().unlocked) + } + runtime.androidHomeOperation(backend, clock).use { home -> + home.cancel() + val error = assertThrows(MobileHomeFfiException.Failed::class.java) { + home.commit("Cancelled commit") + } + assertEquals(MobileHomeErrorKind.INTERRUPTED, error.kind) + } + } + } + } + @Test fun privateNavigationTraversesWithoutAuthenticationAndRejectsReplacedEntries() { val context = InstrumentationRegistry.getInstrumentation().targetContext val id = "navigation-${UUID.randomUUID()}" @@ -193,6 +270,17 @@ class AndroidOnboardingTest { assertFalse(config.contains("public-test-token")) assertFalse(config.contains("public-replacement-token")) } + val clock = object : MobileClock { + override fun monotonicMilliseconds(): ULong = + android.os.SystemClock.elapsedRealtime().toULong() + } + runtime.androidHomeOperation(backend, clock).use { home -> + assertTrue(home.cached().remoteConfigured) + assertEquals(MobileHomeFreshness.CURRENT, home.fetch().freshness) + // The local main branch is absent from this public repository's master branch. + assertFalse(home.activityKnown()) + assertEquals("Tracked Branch Is Unavailable", home.cached().notice?.title) + } } } diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 77301c6..0d77a45 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -525,6 +525,18 @@ pub struct MobileHomeOperation { #[uniffi::export] impl MobileHomeOperation { + pub fn activity_known(&self) -> bool { + self.operation.activity_known() + } + pub fn identity(&self) -> Result { + self.operation + .identity() + .map(|identity| MobileGitIdentity { + name: identity.name().to_owned(), + email: identity.email().to_owned(), + }) + .map_err(Into::into) + } pub fn progress(&self) -> MobileHomeProgress { let progress = self.operation.progress(); MobileHomeProgress { @@ -569,6 +581,13 @@ impl MobileHomeOperation { self.operation.sync().map(Into::into).map_err(Into::into) } + pub fn refresh_gesture(&self) -> Result { + self.operation + .refresh_gesture() + .map(Into::into) + .map_err(Into::into) + } + pub fn cancel(&self) { self.operation.cancel(); } diff --git a/crates/mobile-bridge/src/private_storage.rs b/crates/mobile-bridge/src/private_storage.rs index cc6ad4b..6fdc7e2 100644 --- a/crates/mobile-bridge/src/private_storage.rs +++ b/crates/mobile-bridge/src/private_storage.rs @@ -96,10 +96,22 @@ pub struct MobilePrivateStorageLayout { #[derive(uniffi::Object)] pub struct MobilePrivateRuntime { - pub(crate) storage: PrivateStorage, + pub(crate) storage: Arc, } #[uniffi::export] impl MobilePrivateRuntime { + pub fn android_home_operation( + &self, + backend: Arc, + clock: Arc, + ) -> Result, crate::MobileHomeFfiError> { + let operation = ironstorage::mobile_home::MobileHomeOperation::with_private( + Arc::clone(&self.storage), + Arc::new(SecretBackendAdapter(backend)), + Arc::new(ClockAdapter(clock)), + )?; + Ok(Arc::new(crate::MobileHomeOperation { operation })) + } pub fn android_onboarding( &self, backend: Arc, @@ -163,30 +175,11 @@ impl MobilePrivateRuntime { backend: Arc, clock: Arc, ) -> Result, crate::MobileAuthenticationFfiError> { - self.storage - .verify_secure_storage(&SecretBackendAdapter(Arc::clone(&backend))) - .map_err(private_auth_error)?; - let config = self - .storage - .configuration() - .map_err(private_auth_error)? - .ok_or_else(|| private_auth_error(PrivateStorageError::ConfigurationMissingWithData))?; - let authentication = - ironstorage::mobile_authentication::MobileAuthentication::with_platform( - config, - Arc::new(SecretBackendAdapter(backend)), - Arc::new(ClockAdapter(clock)), - )?; - Ok(Arc::new(MobileAuthentication { - authentication: Arc::new(authentication), - })) - } -} -fn private_auth_error(error: PrivateStorageError) -> crate::MobileAuthenticationFfiError { - crate::MobileAuthenticationFfiError::Failed { - kind: crate::MobileAuthenticationErrorKind::Configuration, - title: "Private Storage Recovery Required".into(), - detail: error.detail().into(), + let authentication = self.storage.authentication( + Arc::new(SecretBackendAdapter(backend)), + Arc::new(ClockAdapter(clock)), + )?; + Ok(Arc::new(MobileAuthentication { authentication })) } } #[uniffi::export] @@ -198,7 +191,9 @@ pub fn open_mobile_private_runtime( no_backup: directories.no_backup.into(), cache: directories.cache.into(), })?; - Ok(Arc::new(MobilePrivateRuntime { storage })) + Ok(Arc::new(MobilePrivateRuntime { + storage: Arc::new(storage), + })) } #[uniffi::export] diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index d908642..61a180c 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -4,6 +4,7 @@ use std::{ collections::BTreeMap, error::Error, fmt, + path::Path, sync::{Arc, Mutex}, time::Duration, }; @@ -79,7 +80,11 @@ impl MobileAuthenticationError { &self.detail } - fn new(kind: MobileAuthenticationErrorKind, title: &str, detail: impl Into) -> Self { + pub(crate) fn new( + kind: MobileAuthenticationErrorKind, + title: &str, + detail: impl Into, + ) -> Self { Self { kind, title: title.to_owned(), @@ -352,6 +357,32 @@ pub struct MobileAuthentication { } impl MobileAuthentication { + pub(crate) fn matches_configuration( + &self, + config: &Config, + ) -> Result { + let status = self.status()?; + if self.config.source() != config.source() + || self.config.vault() != config.vault() + || self.config.key_material() != config.key_material() + || self.config.default_key() != config.default_key() + || self.config.git_remotes() != config.git_remotes() + || status.git_identity != *config.git_identity() + || status.authentication_timeout != config.authentication_timeout() + || status.biometric_unlock_enabled != config.biometric_unlock_enabled() + { + return Ok(false); + } + let current = Repository::open(config.vault()).map_err(entry_error)?; + Ok(self + .repository + .navigation_revision(Path::new(""), false) + .map_err(entry_error)? + == current + .navigation_revision(Path::new(""), false) + .map_err(entry_error)?) + } + pub fn load() -> Result { let config = Config::load(None).map_err(|error| { MobileAuthenticationError::new( @@ -913,6 +944,7 @@ impl MobileAuthentication { &self, operation: &MobileTotpOperation, ) -> Result { + self.ensure_repository_idle()?; self.ensure_active()?; let _discovery = self.totp_discovery.lock().map_err(|_| { entry_detail( @@ -946,6 +978,7 @@ impl MobileAuthentication { &self, query: &str, ) -> Result, MobileAuthenticationError> { + self.ensure_repository_idle()?; let shared = self.status()?.watch_shared_totp_entries.clone(); let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page( diff --git a/crates/storage/src/mobile_home.rs b/crates/storage/src/mobile_home.rs index 99e8e6b..e0e6f21 100644 --- a/crates/storage/src/mobile_home.rs +++ b/crates/storage/src/mobile_home.rs @@ -7,7 +7,10 @@ use std::{ error::Error, fmt, path::Path, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, time::{SystemTime, UNIX_EPOCH}, }; @@ -21,9 +24,11 @@ use crate::{ mobile_authentication::{ MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError, }, + mobile_private_storage::{PrivateStorage, PrivateStorageError, exclusive_lock}, repository::{Repository, RepositoryError}, secret_store::{ - NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError, + NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore, + SecretStoreBackend, SecretStoreError, }, }; @@ -222,6 +227,7 @@ impl MobileHomeNotice { #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileHomePage { + activity_known: bool, remote_configured: bool, freshness: MobileHomeFreshness, refreshed_at: Option, @@ -271,7 +277,7 @@ impl MobileHomePage { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum MobileHomePhase { Validating, Authenticating, @@ -321,27 +327,113 @@ impl MobileHomeProgress { } pub struct MobileHomeOperation { + activity_known: AtomicBool, authentication: Option>, control: GitOperationControl, phase: Arc>, action: Mutex>, + private: Option, +} + +struct PrivateHomeContext { + storage: Arc, + backend: Arc, } impl MobileHomeOperation { pub fn new(authentication: Option>) -> Self { + Self::with_context(authentication, None) + } + + fn with_context( + authentication: Option>, + private: Option, + ) -> Self { let phase = Arc::new(Mutex::new(MobileHomePhase::Validating)); let observed = Arc::clone(&phase); + let monotonic = private.is_some(); Self { + activity_known: AtomicBool::new(false), + private, authentication, control: GitOperationControl::new(move |phase| { if let Ok(mut current) = observed.lock() { - *current = phase.into(); + if monotonic { + if phase != GitProgressPhase::Refreshing { + *current = (*current).max(phase.into()); + } + } else { + *current = phase.into(); + } } }), phase, action: Mutex::new(None), } } + pub fn with_private( + storage: Arc, + backend: Arc, + clock: Arc, + ) -> Result { + let authentication = storage + .authentication(Arc::clone(&backend), clock) + .map_err(|error| MobileHomeError { + kind: match error.kind() { + crate::mobile_authentication::MobileAuthenticationErrorKind::SecureStorage => { + MobileHomeErrorKind::SecureStorage + } + crate::mobile_authentication::MobileAuthenticationErrorKind::Cancelled => { + MobileHomeErrorKind::Interrupted + } + crate::mobile_authentication::MobileAuthenticationErrorKind::Conflict => { + MobileHomeErrorKind::Conflict + } + crate::mobile_authentication::MobileAuthenticationErrorKind::Configuration + | crate::mobile_authentication::MobileAuthenticationErrorKind::KeyMaterial => { + MobileHomeErrorKind::Configuration + } + _ => MobileHomeErrorKind::Authentication, + }, + title: "Home Is Unavailable", + detail: error.detail().to_owned(), + })?; + Ok(Self::with_context( + Some(authentication), + Some(PrivateHomeContext { storage, backend }), + )) + } + + pub fn identity(&self) -> Result { + Ok(self.load()?.config.git_identity().clone()) + } + pub fn activity_known(&self) -> bool { + self.activity_known.load(Ordering::Acquire) + } + fn observe(&self, page: MobileHomePage) -> MobileHomePage { + self.activity_known + .store(page.activity_known, Ordering::Release); + page + } + + fn load(&self) -> Result { + let storage = MobileHomeStorage::load(self.private.as_ref())?; + if self.private.is_some() + && let Some(authentication) = &self.authentication + && !authentication + .matches_configuration(&storage.config) + .map_err(|_| { + MobileHomeError::from_private(PrivateStorageError::CorruptConfiguration) + })? + { + return Err(MobileHomeError { + kind: MobileHomeErrorKind::Configuration, + title: "Password Store Changed", + detail: "Reload Home before starting another repository action.".into(), + }); + } + Ok(storage) + } pub fn cancel(&self) { self.control.cancel(); } @@ -356,31 +448,37 @@ impl MobileHomeOperation { } pub fn cached(&self) -> Result { - let storage = MobileHomeStorage::load()?; + let storage = self.load()?; let refreshed_at = storage.config.mobile_home_refreshed_at(); - storage.page( - if refreshed_at.is_some() { - MobileHomeFreshness::Cached - } else { - MobileHomeFreshness::NeverRefreshed - }, - refreshed_at, - None, - ) + storage + .page( + if refreshed_at.is_some() { + MobileHomeFreshness::Cached + } else { + MobileHomeFreshness::NeverRefreshed + }, + refreshed_at, + None, + ) + .map(|page| self.observe(page)) } pub fn refresh_if_stale(&self) -> Result { - let storage = MobileHomeStorage::load()?; + let storage = self.load()?; let now = unix_seconds()?; if storage.remote.is_none() { - return storage.page(MobileHomeFreshness::Current, Some(now), None); + return storage + .page(MobileHomeFreshness::Current, Some(now), None) + .map(|page| self.observe(page)); } if !is_stale(storage.config.mobile_home_refreshed_at(), now) { - return storage.page( - MobileHomeFreshness::Cached, - storage.config.mobile_home_refreshed_at(), - None, - ); + return storage + .page( + MobileHomeFreshness::Cached, + storage.config.mobile_home_refreshed_at(), + None, + ) + .map(|page| self.observe(page)); } self.run(MobileHomeActionKind::Fetch, || { storage.fetch(now, &self.control) @@ -392,35 +490,47 @@ impl MobileHomeOperation { return Err(MobileHomeError::invalid_commit_message()); } self.run(MobileHomeActionKind::Commit, || { - MobileHomeStorage::load()?.commit(message, &self.control) + self.load()?.commit(message, &self.control) }) } pub fn fetch(&self) -> Result { self.run(MobileHomeActionKind::Fetch, || { - MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control) + self.load()?.fetch(unix_seconds()?, &self.control) }) } pub fn pull(&self) -> Result { self.run(MobileHomeActionKind::Pull, || { - MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control) + self.load()?.pull(unix_seconds()?, &self.control) }) } pub fn push(&self) -> Result { self.run(MobileHomeActionKind::Push, || { - MobileHomeStorage::load()?.push(unix_seconds()?, &self.control) + self.load()?.push(unix_seconds()?, &self.control) }) } pub fn sync(&self) -> Result { // Hold the pull reservation across both steps: sync can replace local entries. self.run(MobileHomeActionKind::Pull, || { - MobileHomeStorage::load()?.sync(unix_seconds()?, &self.control) + self.load()?.sync(unix_seconds()?, &self.control) }) } + /// The Android refresh gesture shares the pull-before-push contract; local stores stay offline. + pub fn refresh_gesture(&self) -> Result { + let storage = self.load()?; + if storage.remote.is_none() { + return storage + .page(MobileHomeFreshness::Current, Some(unix_seconds()?), None) + .map(|page| self.observe(page)); + } + drop(storage); + self.sync() + } + fn run( &self, action: MobileHomeActionKind, @@ -439,6 +549,16 @@ impl MobileHomeOperation { }) .transpose()?; let result = operation(); + if let Ok(page) = &result { + self.activity_known + .store(page.activity_known, Ordering::Release); + } + if self.private.is_some() + && result.is_ok() + && let Ok(mut phase) = self.phase.lock() + { + *phase = MobileHomePhase::Finishing; + } drop(reservation); result } @@ -448,20 +568,57 @@ struct MobileHomeStorage { config: Config, git: GitRepository, remote: Option, + backend: Option>, + _private_lock: Option, } impl MobileHomeStorage { - fn load() -> Result { - let config = Config::load(None).map_err(MobileHomeError::from_config)?; + fn load(private: Option<&PrivateHomeContext>) -> Result { + let (config, backend, lock) = if let Some(private) = private { + private + .storage + .verify_secure_storage(private.backend.as_ref()) + .map_err(MobileHomeError::from_private)?; + let root = cap_std::fs::Dir::open_ambient_dir( + private.storage.root(), + cap_std::ambient_authority(), + ) + .map_err(|_| MobileHomeError::from_private(PrivateStorageError::Io))?; + let lock = + exclusive_lock(&root, "runtime.lock").map_err(MobileHomeError::from_private)?; + let config = private + .storage + .configuration() + .map_err(MobileHomeError::from_private)? + .ok_or_else(|| { + MobileHomeError::from_private(PrivateStorageError::ConfigurationMissingWithData) + })?; + (config, Some(Arc::clone(&private.backend)), Some(lock)) + } else { + ( + Config::load(None).map_err(MobileHomeError::from_config)?, + None, + None, + ) + }; let repository = Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?; let git = GitRepository::open(&repository, config.git_identity().clone()) .map_err(MobileHomeError::from_git)?; let remote = config.git_remote(None).cloned(); + if private.is_some() + && remote + .as_ref() + .is_some_and(|remote| remote.endpoint().as_https().is_none()) + { + return Err(MobileHomeError::from_git(GitError::ForbiddenRemoteUrl)); + } Ok(Self { config, git, remote, + backend, + _private_lock: lock, }) } @@ -479,7 +636,7 @@ impl MobileHomeStorage { .map_err(MobileHomeError::from_git)?; control .report(GitProgressPhase::Refreshing) - .map_err(MobileHomeError::from_git)?; + .map_err(|error| MobileHomeError::after_commit(MobileHomeError::from_git(error)))?; self.page( MobileHomeFreshness::Cached, self.config.mobile_home_refreshed_at(), @@ -492,15 +649,20 @@ impl MobileHomeStorage { &self, now: i64, control: &GitOperationControl, + ) -> Result { + self.fetch_using(now, control, &crate::git::EmbeddedFetchTransport) + } + fn fetch_using( + &self, + now: i64, + control: &GitOperationControl, + transport: &impl crate::git::GitFetchTransport, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; - let fetched = self.git.fetch_with_transport_controlled( - remote, - &store, - &crate::git::EmbeddedFetchTransport, - control, - ); + let fetched = self + .git + .fetch_with_transport_controlled(remote, &store, transport, control); let locked = store.lock(); let outcome = fetched.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { @@ -508,7 +670,7 @@ impl MobileHomeStorage { } control .report(GitProgressPhase::Refreshing) - .map_err(MobileHomeError::from_git)?; + .map_err(|error| MobileHomeError::after_refresh(MobileHomeError::from_git(error)))?; self.current_page(now, Some(fetch_notice(&outcome))) } @@ -516,16 +678,20 @@ impl MobileHomeStorage { &self, now: i64, control: &GitOperationControl, + ) -> Result { + self.pull_using(now, control, &crate::git::EmbeddedFetchTransport) + } + fn pull_using( + &self, + now: i64, + control: &GitOperationControl, + transport: &impl crate::git::GitFetchTransport, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; - let pulled = self.git.pull_with_transport_controlled( - remote, - None, - &store, - &crate::git::EmbeddedFetchTransport, - control, - ); + let pulled = self + .git + .pull_with_transport_controlled(remote, None, &store, transport, control); let locked = store.lock(); let outcome = pulled.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { @@ -533,7 +699,7 @@ impl MobileHomeStorage { } control .report(GitProgressPhase::Refreshing) - .map_err(MobileHomeError::from_git)?; + .map_err(|error| MobileHomeError::after_pull(MobileHomeError::from_git(error)))?; self.current_page(now, Some(pull_notice(outcome))) .map_err(MobileHomeError::after_pull) } @@ -542,16 +708,20 @@ impl MobileHomeStorage { &self, now: i64, control: &GitOperationControl, + ) -> Result { + self.push_using(now, control, &crate::git::ReqwestGitTransport) + } + fn push_using( + &self, + now: i64, + control: &GitOperationControl, + transport: &impl crate::git::GitSmartHttpTransport, ) -> Result { let remote = self.remote()?; let store = self.credentials()?; - let pushed = self.git.push_with_transport_controlled( - remote, - None, - &store, - &crate::git::ReqwestGitTransport, - control, - ); + let pushed = self + .git + .push_with_transport_controlled(remote, None, &store, transport, control); let locked = store.lock(); let outcome = pushed.map_err(MobileHomeError::from_git)?; if let Err(error) = locked { @@ -559,7 +729,7 @@ impl MobileHomeStorage { } control .report(GitProgressPhase::Refreshing) - .map_err(MobileHomeError::from_git)?; + .map_err(|error| MobileHomeError::after_push(MobileHomeError::from_git(error)))?; self.current_page(now, Some(push_notice(&outcome))) .map_err(MobileHomeError::after_push) } @@ -577,7 +747,7 @@ impl MobileHomeStorage { locked.map_err(MobileHomeError::partial_secret)?; control .report(GitProgressPhase::Refreshing) - .map_err(MobileHomeError::from_git)?; + .map_err(|error| MobileHomeError::after_push(MobileHomeError::from_git(error)))?; let mut notice = push_notice(&pushed); notice.title = "Sync Completed".to_owned(); notice.detail = format!("Pull completed successfully. {}", notice.detail); @@ -585,12 +755,16 @@ impl MobileHomeStorage { .map_err(MobileHomeError::after_push) } - fn credentials(&self) -> Result { - let store = NativeSecretStore::system( + fn credentials(&self) -> Result>, MobileHomeError> { + let backend = match &self.backend { + Some(backend) => Arc::clone(backend), + None => Arc::new(NativeSecretBackend::new().map_err(MobileHomeError::from_secret)?), + }; + let store = SecretStore::new( + backend, SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), - ) - .map_err(MobileHomeError::from_secret)?; + ); store.unlock().map_err(MobileHomeError::from_secret)?; Ok(store) } @@ -630,29 +804,90 @@ impl MobileHomeStorage { refreshed_at: Option, notice: Option, ) -> Result { - if let Some(remote) = self.remote.as_ref() { - let divergence = self - .git - .divergence(remote, ACTIVITY_LIMIT) - .map_err(MobileHomeError::from_git)?; - Ok(page_from_divergence( - &divergence, - freshness, - refreshed_at, - notice, - )) + let mut page = if let Some(remote) = self.remote.as_ref() { + match self.git.divergence(remote, ACTIVITY_LIMIT) { + Ok(divergence) => { + page_from_divergence(&divergence, freshness, refreshed_at, notice) + } + Err(GitError::RemoteNotFound { .. }) if self.backend.is_some() => { + let snapshot = self + .git + .snapshot(None, ACTIVITY_LIMIT) + .map_err(MobileHomeError::from_git)?; + let mut page = + page_from_local_snapshot(&snapshot, freshness, refreshed_at, notice); + page.remote_configured = true; + page.summaries[0] = MobileHomeSummaryRow { + id: "tracked-branch".into(), + title: format!("{}/{}", remote.name().as_str(), snapshot.branch()), + detail: if refreshed_at.is_some() { + "HTTPS branch · tracking reference unavailable" + } else { + "HTTPS branch · fetch remote activity first" + } + .into(), + icon: MobileIcon::Branch, + actions: vec![ + mobile_action(MobileHomeActionKind::Fetch), + mobile_action(MobileHomeActionKind::Pull), + mobile_action(MobileHomeActionKind::Push), + ], + }; + let completed = page.notice.take().map_or(String::new(), |notice| { + format!("{} {} ", notice.title, notice.detail) + }); + page.notice = Some(MobileHomeNotice { + title: "Tracked Branch Is Unavailable".into(), + detail: format!( + "{completed}Incoming and outgoing counts are unknown because this local branch has no fetched tracking reference. Local status remains available. Fetch before checking the branch configuration or publishing it." + ), + icon: MobileIcon::Refresh, + }); + page + } + Err(error) => return Err(MobileHomeError::from_git(error)), + } } else { let snapshot = self .git .snapshot(None, ACTIVITY_LIMIT) .map_err(MobileHomeError::from_git)?; - Ok(page_from_local_snapshot( - &snapshot, - freshness, - refreshed_at, - notice, - )) + page_from_local_snapshot(&snapshot, freshness, refreshed_at, notice) + }; + if self.backend.is_some() { + let status = self.git.status().map_err(MobileHomeError::from_git)?; + let changes = status + .staged() + .iter() + .chain(status.unstaged()) + .map(|change| (change.path(), change)) + .collect::>(); + page.summaries + .extend(changes.values().take(ACTIVITY_LIMIT).map(|change| { + let object = mobile_change( + format!("local:{}", change.path().display()), + change.path(), + change.kind(), + ); + MobileHomeSummaryRow { + id: object.id, + title: object.title, + detail: object.detail, + icon: object.icon, + actions: vec![mobile_action(MobileHomeActionKind::Commit)], + } + })); + if changes.len() > ACTIVITY_LIMIT { + page.summaries.push(MobileHomeSummaryRow { + id: "local-more".into(), + title: format!("{} more local changes", changes.len() - ACTIVITY_LIMIT), + detail: "All changes remain included when committing.".into(), + icon: MobileIcon::History, + actions: vec![mobile_action(MobileHomeActionKind::Commit)], + }); + } } + Ok(page) } } @@ -679,6 +914,17 @@ pub struct MobileHomeError { } impl MobileHomeError { + fn from_private(error: PrivateStorageError) -> Self { + Self { + kind: if error == PrivateStorageError::Busy { + MobileHomeErrorKind::Interrupted + } else { + MobileHomeErrorKind::SecureStorage + }, + title: "Private Storage Needs Attention", + detail: error.detail().into(), + } + } pub fn kind(&self) -> MobileHomeErrorKind { self.kind } @@ -781,6 +1027,11 @@ impl MobileHomeError { detail: "The HTTPS server identity could not be verified. Cached activity was not replaced." .to_owned(), }, + GitError::PushOutcomeUnknown { .. } => Self { + kind: MobileHomeErrorKind::PartialProgress, + title: "Push Outcome Is Uncertain", + detail: "The server may have accepted the push. Fetch and inspect current activity before deciding whether to push again.".into(), + }, GitError::Cancelled => Self { kind: MobileHomeErrorKind::Interrupted, title: "Refresh Interrupted", @@ -793,6 +1044,11 @@ impl MobileHomeError { detail: "The configured credential-free HTTPS remote does not match the local clone." .to_owned(), }, + GitError::RemoteNotFound { .. } => Self { + kind: MobileHomeErrorKind::Configuration, + title: "Tracked Branch Is Unavailable", + detail: "Fetch remote activity, then verify that this store uses the intended repository branch in Preferences.".into(), + }, _ => Self { kind: MobileHomeErrorKind::Repository, title: "Remote Activity Is Unavailable", @@ -989,6 +1245,7 @@ fn page_from_divergence( }, ]; MobileHomePage { + activity_known: true, remote_configured: true, freshness, refreshed_at, @@ -1034,6 +1291,7 @@ fn page_from_local_snapshot( }) .map(|line| display_text(line.trim(), 160)); MobileHomePage { + activity_known: false, remote_configured: false, freshness, refreshed_at, @@ -1387,6 +1645,10 @@ fn is_stale(refreshed_at: Option, now: i64) -> bool { ) } +#[cfg(test)] +#[path = "mobile_home/android_tests.rs"] +mod android_tests; + #[cfg(test)] mod tests { use std::path::Path; diff --git a/crates/storage/src/mobile_home/android_tests.rs b/crates/storage/src/mobile_home/android_tests.rs new file mode 100644 index 0000000..7b292b6 --- /dev/null +++ b/crates/storage/src/mobile_home/android_tests.rs @@ -0,0 +1,370 @@ +use super::*; +use crate::{ + authentication::SystemAuthenticationClock, + git::{GitCredential, GitFetchTransport, GitRemoteCredentialProvider, GitSmartHttpTransport}, + mobile_onboarding::android::tests::{Backend, home_fixture}, + repository::SecretBytes, +}; +use std::{fs, sync::mpsc, thread, time::Duration}; + +fn connected() -> (tempfile::TempDir, Arc, Arc) { + let fixture = home_fixture(); + let repository = Repository::open(fixture.1.root().join("vault")).unwrap(); + GitRepository::open(&repository, crate::git::GitIdentity::ironstorage()) + .unwrap() + .add_remote("origin", "https://example.test/team/passwords.git") + .unwrap(); + let config = fixture.1.configuration().unwrap().unwrap(); + let (server, app) = config.git_remotes()[0].https_credentials().unwrap(); + let store = SecretStore::new( + fixture.2.clone(), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + store.unlock().unwrap(); + store + .store_https_git_credential( + server, + app, + "fixture-account", + SecretBytes::new(b"public-fixture-token".to_vec()), + ) + .unwrap(); + store.lock().unwrap(); + fixture +} +fn operation(private: &Arc, backend: &Arc) -> MobileHomeOperation { + MobileHomeOperation::with_private( + private.clone(), + backend.clone(), + Arc::new(SystemAuthenticationClock::default()), + ) + .unwrap() +} + +#[test] +fn private_home_keeps_transient_private_storage_retry_guidance() { + let (_temp, private, backend) = connected(); + let root = + cap_std::fs::Dir::open_ambient_dir(private.root(), cap_std::ambient_authority()).unwrap(); + let lock = exclusive_lock(&root, "runtime.lock").unwrap(); + let error = MobileHomeOperation::with_private( + private.clone(), + backend.clone(), + Arc::new(SystemAuthenticationClock::default()), + ) + .err() + .unwrap(); + assert_eq!(error.kind(), MobileHomeErrorKind::Configuration); + assert!(error.detail().contains("Retry later")); + assert!( + !error + .detail() + .contains("Protected device storage is unavailable") + ); + drop(lock); + assert!(operation(&private, &backend).cached().is_ok()); +} + +struct Fetch(Option); +impl GitFetchTransport for Fetch { + fn fetch( + &self, + repository: &GitRepository, + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, + ) -> Result { + let credential = credentials.credential_for(configured)?; + assert_eq!(credential.username(), "fixture-account"); + assert_eq!(credential.password(), b"public-fixture-token"); + if let Some(error) = &self.0 { + return Err(error.clone()); + } + let snapshot = repository.snapshot(None, 1)?; + let tracking = repository.root().join(format!( + ".git/refs/remotes/{}/{}", + configured.name().as_str(), + snapshot.branch() + )); + fs::create_dir_all(tracking.parent().unwrap()).unwrap(); + fs::write(tracking, format!("{}\n", snapshot.recent()[0].id())).unwrap(); + Ok(false) + } +} + +struct AbsentBranch; +impl GitFetchTransport for AbsentBranch { + fn fetch( + &self, + _: &GitRepository, + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, + ) -> Result { + assert_eq!( + credentials.credential_for(configured)?.username(), + "fixture-account" + ); + Ok(false) + } +} + +#[test] +fn private_home_successful_fetch_records_freshness_even_when_the_local_branch_is_absent() { + let (_temp, private, backend) = connected(); + let home = operation(&private, &backend); + let page = home + .run(MobileHomeActionKind::Fetch, || { + home.load()?.fetch_using(500, &home.control, &AbsentBranch) + }) + .unwrap(); + assert_eq!(page.freshness(), MobileHomeFreshness::Current); + assert_eq!(page.refreshed_at(), Some(500)); + assert!(!home.activity_known()); + let notice = page.notice().unwrap(); + assert_eq!(notice.title(), "Tracked Branch Is Unavailable"); + assert!(notice.detail().contains("Remote Already Current")); + let cached = operation(&private, &backend).cached().unwrap(); + assert_eq!(cached.freshness(), MobileHomeFreshness::Cached); + assert_eq!(cached.refreshed_at(), Some(500)); +} +fn packet(value: &[u8]) -> Vec { + let mut output = format!("{:04x}", value.len() + 4).into_bytes(); + output.extend_from_slice(value); + output +} +struct Push { + old: String, + branch: String, + request: Mutex>, +} +impl GitSmartHttpTransport for Push { + fn advertise_receive_pack( + &self, + _: &url::Url, + credential: &GitCredential, + ) -> Result, GitError> { + assert_eq!(credential.username(), "fixture-account"); + assert_eq!(credential.password(), b"public-fixture-token"); + let mut data = packet(b"# service=git-receive-pack\n"); + data.extend_from_slice(b"0000"); + data.extend(packet( + format!("{} refs/heads/{}\0report-status\n", self.old, self.branch).as_bytes(), + )); + data.extend_from_slice(b"0000"); + Ok(data) + } + fn receive_pack( + &self, + _: &url::Url, + _: &GitCredential, + request: Vec, + ) -> Result, GitError> { + *self.request.lock().unwrap() = request; + let mut data = packet(b"unpack ok\n"); + data.extend(packet( + format!("ok refs/heads/{}\n", self.branch).as_bytes(), + )); + data.extend_from_slice(b"0000"); + Ok(data) + } +} + +#[test] +fn private_home_fetch_pull_and_real_pack_push_use_only_the_injected_application_credential() { + let (_temp, private, backend) = connected(); + let home = operation(&private, &backend); + home.cached().unwrap(); + assert!(!home.activity_known()); + home.run(MobileHomeActionKind::Fetch, || { + home.load()?.fetch_using(100, &home.control, &Fetch(None)) + }) + .unwrap(); + assert!(home.activity_known()); + let pull = operation(&private, &backend); + assert_eq!( + pull.run(MobileHomeActionKind::Pull, || pull.load()?.pull_using( + 101, + &pull.control, + &Fetch(None) + )) + .unwrap() + .notice() + .unwrap() + .title(), + "Already Up to Date" + ); + let repository = Repository::open(private.root().join("vault")).unwrap(); + let git = GitRepository::open(&repository, crate::git::GitIdentity::ironstorage()).unwrap(); + let snapshot = git.snapshot(None, 1).unwrap(); + let push = Push { + old: snapshot.recent()[0].id().into(), + branch: snapshot.branch().into(), + request: Mutex::new(Vec::new()), + }; + fs::write( + private.root().join("vault/changed.gpg"), + b"encrypted fixture", + ) + .unwrap(); + operation(&private, &backend) + .commit("Commit before push") + .unwrap(); + let home = operation(&private, &backend); + let page = home + .run(MobileHomeActionKind::Push, || { + home.load()?.push_using(102, &home.control, &push) + }) + .unwrap(); + assert_eq!(page.notice().unwrap().title(), "Changes Pushed"); + assert!( + push.request + .lock() + .unwrap() + .windows(4) + .any(|bytes| bytes == b"PACK") + ); + assert!(git.status().unwrap().is_clean()); + assert_eq!(home.progress().phase(), MobileHomePhase::Finishing); +} + +#[test] +fn private_home_offline_tls_and_authentication_failures_keep_cached_activity_recoverable() { + let (_temp, private, backend) = connected(); + for (error, expected) in [ + (GitError::NetworkUnavailable, MobileHomeErrorKind::Offline), + (GitError::TlsFailed, MobileHomeErrorKind::Offline), + ( + GitError::AuthenticationFailed, + MobileHomeErrorKind::Authentication, + ), + ] { + let home = operation(&private, &backend); + let cached = home.cached().unwrap(); + let failed = home + .run(MobileHomeActionKind::Fetch, || { + home.load()? + .fetch_using(200, &home.control, &Fetch(Some(error))) + }) + .unwrap_err(); + assert_eq!(failed.kind(), expected); + assert!(!failed.to_string().contains("public-fixture-token")); + assert_eq!(cached, operation(&private, &backend).cached().unwrap()); + } +} + +#[test] +fn private_home_cancellation_after_commit_reports_completion_without_replaying_it() { + let (_temp, private, backend) = connected(); + fs::write( + private.root().join("vault/completed.gpg"), + b"encrypted fixture", + ) + .unwrap(); + let observed = Arc::new(Mutex::new(None::)); + let cancel = observed.clone(); + let control = GitOperationControl::new(move |phase| { + if phase == GitProgressPhase::Refreshing { + cancel.lock().unwrap().as_ref().unwrap().cancel(); + } + }); + *observed.lock().unwrap() = Some(control.clone()); + let home = operation(&private, &backend); + let error = home + .load() + .unwrap() + .commit("Completed once", &control) + .unwrap_err(); + observed.lock().unwrap().take(); + assert_eq!(error.kind(), MobileHomeErrorKind::PartialProgress); + assert_eq!(error.title(), "Commit Completed, Status Unavailable"); + let repository = Repository::open(private.root().join("vault")).unwrap(); + let git = GitRepository::open(&repository, crate::git::GitIdentity::ironstorage()).unwrap(); + assert!(git.status().unwrap().is_clean()); + assert_eq!( + git.snapshot(None, 1).unwrap().recent()[0].message(), + "Completed once" + ); + assert_eq!( + operation(&private, &backend) + .commit("Retry") + .unwrap_err() + .kind(), + MobileHomeErrorKind::NoChanges + ); +} + +struct SlowFetch(mpsc::Sender<()>); +impl GitFetchTransport for SlowFetch { + fn fetch( + &self, + _: &GitRepository, + _: &GitRemote, + _: &dyn GitRemoteCredentialProvider, + ) -> Result { + unreachable!("controlled transport required") + } + fn fetch_controlled( + &self, + _: &GitRepository, + configured: &GitRemote, + credentials: &dyn GitRemoteCredentialProvider, + control: &GitOperationControl, + ) -> Result { + assert_eq!( + credentials.credential_for(configured)?.password(), + b"public-fixture-token" + ); + control.report(GitProgressPhase::Receiving)?; + self.0.send(()).unwrap(); + while !control.is_cancelled() { + control.report(GitProgressPhase::Authenticating)?; + thread::sleep(Duration::from_millis(10)); + } + Err(GitError::Cancelled) + } +} +#[test] +fn private_home_slow_network_progress_is_monotonic_and_cancel_then_retry_preserves_the_store() { + let (_temp, private, backend) = connected(); + let home = Arc::new(operation(&private, &backend)); + let (started, received) = mpsc::channel(); + let worker = home.clone(); + let thread = thread::spawn(move || { + worker.run(MobileHomeActionKind::Fetch, || { + worker + .load()? + .fetch_using(300, &worker.control, &SlowFetch(started)) + }) + }); + received.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!( + home.authentication + .as_ref() + .unwrap() + .cached_totp_page() + .unwrap_err() + .title() + .contains("Repository Action") + ); + assert_eq!(home.progress().phase(), MobileHomePhase::Receiving); + home.cancel(); + assert_eq!( + thread.join().unwrap().unwrap_err().kind(), + MobileHomeErrorKind::Interrupted + ); + let retry = operation(&private, &backend); + retry + .run(MobileHomeActionKind::Fetch, || { + retry.load()?.fetch_using(301, &retry.control, &Fetch(None)) + }) + .unwrap(); + assert!(retry.activity_known()); + let repository = Repository::open(private.root().join("vault")).unwrap(); + assert!( + GitRepository::open(&repository, crate::git::GitIdentity::ironstorage()) + .unwrap() + .status() + .unwrap() + .is_clean() + ); +} diff --git a/crates/storage/src/mobile_onboarding/android.rs b/crates/storage/src/mobile_onboarding/android.rs index 5c19838..41a8946 100644 --- a/crates/storage/src/mobile_onboarding/android.rs +++ b/crates/storage/src/mobile_onboarding/android.rs @@ -895,7 +895,7 @@ fn pending_locator(journal: &Journal) -> Option { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::{ git::{GitFetchTransport, GitRemoteCredentialProvider}, @@ -909,7 +909,7 @@ mod tests { Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) } #[derive(Default)] - struct Backend { + pub(crate) struct Backend { records: Mutex>>, fail_tokens: AtomicBool, } @@ -1510,4 +1510,197 @@ mod tests { b"retained password store" ); } + + pub(crate) fn home_fixture() -> (tempfile::TempDir, Arc, Arc) { + let temp = tempfile::tempdir().unwrap(); + let backend = Arc::new(Backend::default()); + let private = Arc::new(storage(temp.path())); + imported(&private, &backend) + .setup_local(ALICE, password()) + .unwrap(); + (temp, private, backend) + } + fn home( + private: &Arc, + backend: &Arc, + ) -> crate::mobile_home::MobileHomeOperation { + crate::mobile_home::MobileHomeOperation::with_private( + Arc::clone(private), + backend.clone(), + Arc::new(crate::authentication::SystemAuthenticationClock::default()), + ) + .unwrap() + } + + #[test] + fn private_home_commits_with_configured_identity_without_entry_authentication() { + use crate::mobile_home::{MobileHomeActionKind, MobileHomeFreshness, MobileHomePhase}; + let (_temp, private, backend) = home_fixture(); + let config = private.configuration().unwrap().unwrap(); + config + .update_git_identity( + &GitIdentity::new("Android fixture", "fixture@example.test").unwrap(), + ) + .unwrap(); + fs::write( + config.vault().join("仕事.gpg"), + b"ciphertext-only navigation fixture", + ) + .unwrap(); + let operation = home(&private, &backend); + let page = operation.cached().unwrap(); + assert!( + page.summaries().iter().any(|row| row.title() == "仕事" + && row.actions()[0].kind() == MobileHomeActionKind::Commit) + ); + assert_eq!(operation.identity().unwrap().name(), "Android fixture"); + let committed = operation.commit("Commit encrypted fixture").unwrap(); + assert_eq!(committed.notice().unwrap().title(), "Changes Committed"); + assert_eq!(operation.progress().phase(), MobileHomePhase::Finishing); + let repo = Repository::open(config.vault()).unwrap(); + let git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap(); + assert!(git.status().unwrap().is_clean()); + assert_eq!( + git.snapshot(None, 1).unwrap().recent()[0].author_name(), + "Android fixture" + ); + assert_eq!( + home(&private, &backend) + .refresh_gesture() + .unwrap() + .freshness(), + MobileHomeFreshness::Current + ); + } + + #[test] + fn private_home_shares_the_rust_owner_and_rejects_dirty_editor_pull_and_push() { + use crate::mobile_home::MobileHomeErrorKind; + let (_temp, private, backend) = home_fixture(); + let clock = Arc::new(crate::authentication::SystemAuthenticationClock::default()); + let authentication = private + .authentication(backend.clone(), clock.clone()) + .unwrap(); + let same = private.authentication(backend.clone(), clock).unwrap(); + assert!(Arc::ptr_eq(&authentication, &same)); + let draft = authentication + .begin_create_entry("", "draft", password()) + .unwrap(); + let operation = home(&private, &backend); + assert_eq!( + operation.pull().unwrap_err().kind(), + MobileHomeErrorKind::DirtyLocalChanges + ); + assert_eq!( + operation.push().unwrap_err().kind(), + MobileHomeErrorKind::DirtyLocalChanges + ); + assert!(authentication.entry_editor(draft.id()).unwrap().dirty()); + assert!(!private.root().join("vault/draft.gpg").exists()); + authentication.discard_entry_editor(draft.id()).unwrap(); + private + .configuration() + .unwrap() + .unwrap() + .update_mobile_home_refresh(100) + .unwrap(); + assert!(Arc::ptr_eq( + &authentication, + &private + .authentication( + backend.clone(), + Arc::new(crate::authentication::SystemAuthenticationClock::default()) + ) + .unwrap() + )); + authentication.manual_lock().unwrap(); + home(&private, &backend).cached().unwrap(); + assert!(!authentication.state().unwrap().unlocked()); + } + + #[test] + fn private_home_cancelled_commit_preserves_the_worktree_and_retry_is_explicit() { + use crate::mobile_home::MobileHomeErrorKind; + let (_temp, private, backend) = home_fixture(); + let path = private.root().join("vault/retained.gpg"); + fs::write(&path, b"retained encrypted bytes").unwrap(); + let operation = home(&private, &backend); + operation.cancel(); + assert_eq!( + operation.commit("Cancelled commit").unwrap_err().kind(), + MobileHomeErrorKind::Interrupted + ); + assert_eq!(fs::read(&path).unwrap(), b"retained encrypted bytes"); + let repo = Repository::open(private.root().join("vault")).unwrap(); + let git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap(); + assert!(!git.status().unwrap().is_clean()); + home(&private, &backend).commit("Explicit retry").unwrap(); + assert!(git.status().unwrap().is_clean()); + } + + #[test] + fn private_home_bounds_large_local_change_presentations_but_commits_every_object() { + let (_temp, private, backend) = home_fixture(); + for number in 0..200 { + fs::write( + private.root().join(format!("vault/item-{number}.gpg")), + b"encrypted fixture", + ) + .unwrap(); + } + let page = home(&private, &backend).cached().unwrap(); + assert_eq!( + page.summaries() + .iter() + .filter(|row| row.id().starts_with("local:")) + .count(), + 50 + ); + assert!(page.summaries().iter().any(|row| row.id() == "local-more")); + home(&private, &backend) + .commit("Commit all 200 encrypted objects") + .unwrap(); + let repo = Repository::open(private.root().join("vault")).unwrap(); + assert!( + GitRepository::open(&repo, GitIdentity::ironstorage()) + .unwrap() + .status() + .unwrap() + .is_clean() + ); + } + + #[test] + fn private_home_unfetched_https_remote_keeps_local_status_and_network_actions_available() { + use crate::mobile_home::{MobileHomeActionKind, MobileHomeErrorKind}; + let (_temp, private, backend) = home_fixture(); + let repo = Repository::open(private.root().join("vault")).unwrap(); + let mut git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap(); + git.add_remote("origin", "https://example.test/team/passwords.git") + .unwrap(); + let operation = home(&private, &backend); + let page = operation.cached().unwrap(); + assert!(page.remote_configured()); + assert_eq!( + page.summaries()[0] + .actions() + .iter() + .map(|action| action.kind()) + .collect::>(), + vec![ + MobileHomeActionKind::Fetch, + MobileHomeActionKind::Pull, + MobileHomeActionKind::Push + ] + ); + assert_eq!( + page.notice().unwrap().title(), + "Tracked Branch Is Unavailable" + ); + assert_eq!( + operation.fetch().unwrap_err().kind(), + MobileHomeErrorKind::Authentication + ); + assert!(git.status().unwrap().is_clean()); + } } diff --git a/crates/storage/src/mobile_private_storage.rs b/crates/storage/src/mobile_private_storage.rs index a6f4bd7..1ebde3c 100644 --- a/crates/storage/src/mobile_private_storage.rs +++ b/crates/storage/src/mobile_private_storage.rs @@ -4,6 +4,9 @@ use crate::atomic_file::TempFile; use crate::{ config::Config, mobile::MobileShell, + mobile_authentication::{ + MobileAuthentication, MobileAuthenticationError, MobileAuthenticationErrorKind, + }, secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}, }; use cap_std::{ @@ -17,7 +20,7 @@ use std::{ fmt, fs, io::{self, Read, Write}, path::{Component, Path, PathBuf}, - sync::Mutex, + sync::{Arc, Mutex}, }; const VERSION: u32 = 1; @@ -136,6 +139,7 @@ pub struct PrivateStorage { data: Dir, cache: Dir, status: Mutex, + authentication: Mutex>>, } impl PrivateStorage { @@ -228,6 +232,7 @@ impl PrivateStorage { }); let root = roots.no_backup.join("ironstorage"); let mut storage = Self { + authentication: Mutex::new(None), root, cache_path: roots.cache.join("ironstorage"), data, @@ -286,6 +291,39 @@ impl PrivateStorage { pub fn root(&self) -> &Path { &self.root } + /// One Rust owner coordinates leases, drafts and Git reservations for this runtime. + pub fn authentication( + &self, + backend: Arc, + clock: Arc, + ) -> Result, MobileAuthenticationError> { + let recovery = |error: PrivateStorageError| { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Configuration, + "Private Storage Recovery Required", + error.detail(), + ) + }; + self.verify_secure_storage(backend.as_ref()) + .map_err(recovery)?; + let config = self + .configuration() + .map_err(recovery)? + .ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?; + let mut owner = self + .authentication + .lock() + .map_err(|_| recovery(PrivateStorageError::Busy))?; + if let Some(current) = owner.as_ref() { + if current.matches_configuration(&config)? { + return Ok(Arc::clone(current)); + } + current.manual_lock()?; + } + let current = Arc::new(MobileAuthentication::with_platform(config, backend, clock)?); + *owner = Some(Arc::clone(¤t)); + Ok(current) + } /// Every existing default-path API now uses this explicit OS context, never HOME/cwd. pub fn activate(&self) -> Result<(), PrivateStorageError> { if crate::config::ConfigLoader::activate_private_root(&self.root) { diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 785c844..5573e09 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -439,7 +439,11 @@ impl<'a> MobileTotpService<'a> { .entries .into_iter() .filter(|(_, record)| record.is_totp) - .filter_map(|(path, _)| EntryPath::parse(&path).ok()) + .filter_map(|(path, record)| { + let path = EntryPath::parse(&path).ok()?; + let ciphertext = self.repository.read_entry(&path).ok()?; + (digest(ciphertext.as_bytes()) == record.ciphertext_hash).then_some(path) + }) .filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query)) .map(|path| cached_row(&path, shared.contains(&path))) .collect(); diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index 60f3345..e23222a 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -274,11 +274,30 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu ); repository.remove_entry(&EntryPath::parse("otp/alice")?)?; + assert!( + service + .cached_page(&BTreeSet::new(), &cache) + .expect("created cache") + .rows() + .iter() + .all(|row| row.path() != "otp/alice") + ); let deleted = MobileTotpOperation::default(); let mut deleted_secrets = CountingSecrets::all(&fixture); let deleted_page = service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?; assert_eq!(deleted_secrets.requests, 0); + write_plaintext(&repository, &keys, "team-a", b"changed encrypted entry\n")?; + let retained = service + .cached_page(&BTreeSet::new(), &cache) + .expect("created cache"); + assert!(retained.rows().iter().all(|row| row.path() != "team-a")); + assert!( + retained + .rows() + .iter() + .any(|row| row.path() == "team-a/shared") + ); assert!( deleted_page .rows() diff --git a/docs/android/home-sync.md b/docs/android/home-sync.md new file mode 100644 index 0000000..fcdf501 --- /dev/null +++ b/docs/android/home-sync.md @@ -0,0 +1,71 @@ +# Android Home and HTTPS synchronization (#97) + +Home renders the private runtime's typed Rust Git page: tracked branch, freshness +and refresh time, ahead/behind totals, incoming/outgoing commits, changed objects, +notices and permitted actions. An unfetched tracking branch retains local status +and Fetch/Pull/Push controls without presenting unknown remote counts as zero. +Rust bounds activity and local-change presentation to 50 records; committing still +includes all eligible repository changes. Compose uses lazy rows for large pages. + +Commit collects only a message and displays the configured Rust Git name/email. +Fetch updates tracking data; Pull integrates fetched changes; Push sends committed +objects. Entry/foreground refresh fetches stale activity. The primary button and +swipe gesture share Rust's pull-before-push synchronization contract. A local store +refreshes its status without network or authentication. + +All bridge calls use the injected I/O dispatcher. Each request publishes typed +progress, with monotonic Rust phases and a persistent Cancel control above the +scrolling history. A sequence rejects obsolete results; leaving/backgrounding +cancels the active operation and clears the unsaved commit dialog. Reload reads +status and refreshes stale tracking data; it never replays a commit, pull or push. +Completed mutations report partial progress if cancellation or status rebuilding +prevents displaying their result. Ambiguous pushes require Fetch and inspection +before retrying. + +The private runtime retains one Rust authentication owner across navigation, +editors and Home. Existing reservations reject Pull with any open editor and Push +with dirty drafts. Git retains its worktree/conflict checks; Rust revalidates entry revisions +and TOTP catalog ciphertext hashes against repository changes, without decrypting +entries for cached discovery. TOTP discovery and cached-list requests reject an +active repository operation. Successful Pull/sync refreshes the Android tree +and viewer. Configuration/root replacement locks and replaces the shared owner; +updating only the cached Home timestamp does not discard drafts or leases. + +Repository remotes are authoritative. Android accepts configured HTTPS endpoints +only, obtains that application's credential through the injected Rust secure store, +and closes credential state after each operation. Kotlin receives no token, stages +no paths, constructs no commit or remote URL, and interprets no Git text. Offline, +TLS, authentication, conflict, dirty-editor and cancellation errors remain typed, +bounded and recoverable. No interactive Git operation uses WorkManager. + +## Validation + +Rust regressions exercise real local commits and identity, shared draft guards, +unfetched tracking references, 200 local changes with bounded presentation, actual +smart-HTTP pack generation, injected protected credentials, offline/TLS/authentication +errors, monotonic slow-transfer cancellation/retry and cancellation after a completed +commit, plus immediate rejection of changed/deleted cached TOTP entries. Protocol fixtures substitute the transport, not repository or Git behavior. + +Android native instrumentation creates isolated encrypted fixtures through Rust, +shares editor reservations, commits an encrypted entry, exercises typed errors and +cancellation, and checks locked authentication state. An opt-in public HTTPS test +also fetches actual remote activity using the Android certificate verifier. Compose +instrumentation exercises every action, commit identity/message, reload without +mutation replay, swipe refresh, and cancellation while scrolling 1,000 changed +objects. GUI fixtures test presentation; production Home uses the private Rust API. + +The emulator scope is ARM64 API 26/4 KB, API 37/4 KB and strict API 37/16 KB. +ARMv7 is compiled and package-audited; an ARMv7 image is unavailable locally. +Authenticated remote push is covered by Rust protocol tests, rather than sending +the user's private repository credential from an emulator. Physical devices are +excluded by the milestone's user-directed validation scope. API 37 installations +were updated in place. API 26 was reset with explicit user authorization after +its previous emulator state prevented GUI instrumentation from starting. + +Final validation passed all four required Rust gates (473 workspace tests), the +complete Android build/lint/JVM/style/architecture/package/dependency gates, and +app plus native instrumentation on all three emulator configurations. Each +configuration also passed five native cold loads and five application cold starts. +The opt-in public HTTPS native tests passed on API 37. Computer use verified the +production Home page, refresh behavior and history scrolling in Android Studio's +embedded emulator. No Apple project changes were required.