Implement Android Home activity and HTTPS synchronization (#97)
Android ARM verification / rust (push) Failing after 46m50s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 14:45:57 +02:00
1 parent 8c9341f214
commit 5c933a99a3
29 files changed
+2008 -110

No files matched your search

+3 -2
View File
@@ -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
@@ -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<MainActivity>()
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<Pair<HomeActionKind, String>>()
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
}
}
@@ -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 =
@@ -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<HomeAction>
)
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<HomeChange>,
val actions: List<HomeAction>
)
data class HomeNotice(val title: String, val detail: String)
data class HomePage(
val freshness: HomeFreshness,
val refreshedAt: Long?,
val summaries: List<HomeRow>,
val incoming: List<HomeCommit>,
val outgoing: List<HomeCommit>,
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<HomeState>
fun enter()
fun refresh()
fun perform(action: HomeActionKind, message: String = "")
fun reload()
fun cancel()
fun leave()
}
@@ -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<HomeState> = 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
)
}
@@ -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<HomeAction>,
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
)
}
@@ -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,
@@ -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
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M6,2a3,3 0,1 0,0 6v8a3,3 0,1 0,2 0V12h8a4,4 0,0 0,4 -4V7a3,3 0,1 0,-2 0v1a2,2 0,0 1,-2 2H8V8a3,3 0,0 0,-2 -6z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M6,2h9l5,5v15H4V2z M14,4v5h5z M7,12v2h10v-2z M7,16v2h10v-2z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M13,3a9,9 0,1 1,-8.6 12h2.2A7,7 0,1 0,6.4 8H10v2H2V2h2v4A9,9 0,0 1,13 3z M12,7h2v5l3,2 -1,1.6 -4,-2.6z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M11,3h2v12l4,-4 1.4,1.4L12,18 5.6,12.4 7,11l4,4z M5,20h14v2H5z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M11,21h2V9l4,4 1.4,-1.4L12,6 5.6,11.6 7,13l4,-4z M5,2h14v2H5z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M12,2L3,6v6c0,5 4,9 9,10 5,-1 9,-5 9,-10V6z M10,16l-4,-4 1.4,-1.4 2.6,2.6 6.6,-6.6L18,8z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8s3.58,8 8,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,.69 4.22,1.78L13,11h7V4z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M9,16.2L4.8,12 3.4,13.4 9,19 21,7 19.6,5.6z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M1,21h22L12,2 1,21z M13,18h-2v-2h2v2z M13,14h-2v-4h2v4z" />
</vector>
@@ -1,4 +1,23 @@
<resources>
<string name="home_title">Remote activity</string>
<string name="home_loading">Loading Home</string>
<string name="home_cancel">Cancel</string>
<string name="home_refresh">Refresh and sync</string>
<string name="home_reload">Reload status</string>
<string name="home_never">Remote status has not been refreshed</string>
<string name="home_cached">Cached activity</string>
<string name="home_current">Current activity</string>
<string name="home_checked">Last checked: %1$s</string>
<string name="home_incoming">Incoming · %1$s</string>
<string name="home_outgoing">Outgoing · %1$s</string>
<string name="home_no_activity">No commits in this direction.</string>
<plurals name="home_bounded">
<item quantity="one">Showing the latest %1$d commit.</item>
<item quantity="other">Showing the latest %1$d commits.</item>
</plurals>
<string name="home_commit_title">Commit password-store changes</string>
<string name="home_commit_message">Commit message</string>
<string name="home_commit_submit">Create commit</string>
<string name="app_name">IronStorage</string>
<string name="scaffold_title">No Password Store</string>
<string name="scaffold_detail">Your password store has not been configured.</string>
@@ -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)
}
}
}
+19
View File
@@ -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<MobileGitIdentity, MobileHomeFfiError> {
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<MobileHomePage, MobileHomeFfiError> {
self.operation
.refresh_gesture()
.map(Into::into)
.map_err(Into::into)
}
pub fn cancel(&self) {
self.operation.cancel();
}
+21 -26
View File
@@ -96,10 +96,22 @@ pub struct MobilePrivateStorageLayout {
#[derive(uniffi::Object)]
pub struct MobilePrivateRuntime {
pub(crate) storage: PrivateStorage,
pub(crate) storage: Arc<PrivateStorage>,
}
#[uniffi::export]
impl MobilePrivateRuntime {
pub fn android_home_operation(
&self,
backend: Arc<dyn MobileSecretBackend>,
clock: Arc<dyn MobileClock>,
) -> Result<Arc<crate::MobileHomeOperation>, 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<dyn MobileSecretBackend>,
@@ -163,30 +175,11 @@ impl MobilePrivateRuntime {
backend: Arc<dyn MobileSecretBackend>,
clock: Arc<dyn MobileClock>,
) -> Result<Arc<MobileAuthentication>, 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]
+34 -1
View File
@@ -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<String>) -> Self {
pub(crate) fn new(
kind: MobileAuthenticationErrorKind,
title: &str,
detail: impl Into<String>,
) -> 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<bool, MobileAuthenticationError> {
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<Self, MobileAuthenticationError> {
let config = Config::load(None).map_err(|error| {
MobileAuthenticationError::new(
@@ -913,6 +944,7 @@ impl MobileAuthentication {
&self,
operation: &MobileTotpOperation,
) -> Result<MobileTotpPage, MobileAuthenticationError> {
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<Option<MobileTotpPage>, 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(
+336 -74
View File
@@ -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<i64>,
@@ -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<Arc<MobileAuthentication>>,
control: GitOperationControl,
phase: Arc<Mutex<MobileHomePhase>>,
action: Mutex<Option<MobileHomeActionKind>>,
private: Option<PrivateHomeContext>,
}
struct PrivateHomeContext {
storage: Arc<PrivateStorage>,
backend: Arc<dyn SecretStoreBackend>,
}
impl MobileHomeOperation {
pub fn new(authentication: Option<Arc<MobileAuthentication>>) -> Self {
Self::with_context(authentication, None)
}
fn with_context(
authentication: Option<Arc<MobileAuthentication>>,
private: Option<PrivateHomeContext>,
) -> 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<PrivateStorage>,
backend: Arc<dyn SecretStoreBackend>,
clock: Arc<dyn crate::authentication::AuthenticationClock>,
) -> Result<Self, MobileHomeError> {
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<crate::git::GitIdentity, MobileHomeError> {
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<MobileHomeStorage, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Fetch, || {
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
self.load()?.fetch(unix_seconds()?, &self.control)
})
}
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Pull, || {
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
self.load()?.pull(unix_seconds()?, &self.control)
})
}
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Push, || {
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
self.load()?.push(unix_seconds()?, &self.control)
})
}
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
// 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<MobileHomePage, MobileHomeError> {
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<GitRemote>,
backend: Option<Arc<dyn SecretStoreBackend>>,
_private_lock: Option<std::fs::File>,
}
impl MobileHomeStorage {
fn load() -> Result<Self, MobileHomeError> {
let config = Config::load(None).map_err(MobileHomeError::from_config)?;
fn load(private: Option<&PrivateHomeContext>) -> Result<Self, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
self.fetch_using(now, control, &crate::git::EmbeddedFetchTransport)
}
fn fetch_using(
&self,
now: i64,
control: &GitOperationControl,
transport: &impl crate::git::GitFetchTransport,
) -> Result<MobileHomePage, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
self.pull_using(now, control, &crate::git::EmbeddedFetchTransport)
}
fn pull_using(
&self,
now: i64,
control: &GitOperationControl,
transport: &impl crate::git::GitFetchTransport,
) -> Result<MobileHomePage, MobileHomeError> {
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<MobileHomePage, MobileHomeError> {
self.push_using(now, control, &crate::git::ReqwestGitTransport)
}
fn push_using(
&self,
now: i64,
control: &GitOperationControl,
transport: &impl crate::git::GitSmartHttpTransport,
) -> Result<MobileHomePage, MobileHomeError> {
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<NativeSecretStore, MobileHomeError> {
let store = NativeSecretStore::system(
fn credentials(&self) -> Result<SecretStore<Arc<dyn SecretStoreBackend>>, 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<i64>,
notice: Option<MobileHomeNotice>,
) -> Result<MobileHomePage, MobileHomeError> {
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::<std::collections::BTreeMap<_, _>>();
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<i64>, now: i64) -> bool {
)
}
#[cfg(test)]
#[path = "mobile_home/android_tests.rs"]
mod android_tests;
#[cfg(test)]
mod tests {
use std::path::Path;
@@ -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<PrivateStorage>, Arc<Backend>) {
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<PrivateStorage>, backend: &Arc<Backend>) -> 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<GitError>);
impl GitFetchTransport for Fetch {
fn fetch(
&self,
repository: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
) -> Result<bool, GitError> {
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<bool, GitError> {
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<u8> {
let mut output = format!("{:04x}", value.len() + 4).into_bytes();
output.extend_from_slice(value);
output
}
struct Push {
old: String,
branch: String,
request: Mutex<Vec<u8>>,
}
impl GitSmartHttpTransport for Push {
fn advertise_receive_pack(
&self,
_: &url::Url,
credential: &GitCredential,
) -> Result<Vec<u8>, 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<u8>,
) -> Result<Vec<u8>, 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::<GitOperationControl>));
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<bool, GitError> {
unreachable!("controlled transport required")
}
fn fetch_controlled(
&self,
_: &GitRepository,
configured: &GitRemote,
credentials: &dyn GitRemoteCredentialProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
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()
);
}
+195 -2
View File
@@ -895,7 +895,7 @@ fn pending_locator(journal: &Journal) -> Option<SecretLocator> {
}
#[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<BTreeMap<SecretLocator, Vec<u8>>>,
fail_tokens: AtomicBool,
}
@@ -1510,4 +1510,197 @@ mod tests {
b"retained password store"
);
}
pub(crate) fn home_fixture() -> (tempfile::TempDir, Arc<PrivateStorage>, Arc<Backend>) {
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<PrivateStorage>,
backend: &Arc<Backend>,
) -> 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<_>>(),
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());
}
}
+39 -1
View File
@@ -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<PrivateStorageStatus>,
authentication: Mutex<Option<Arc<MobileAuthentication>>>,
}
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<dyn SecretStoreBackend>,
clock: Arc<dyn crate::authentication::AuthenticationClock>,
) -> Result<Arc<MobileAuthentication>, 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(&current));
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) {
+5 -1
View File
@@ -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();
+19
View File
@@ -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()
+71
View File
@@ -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.