Implement Android Home activity and HTTPS synchronization (#97)
This commit is contained in:
1 parent
8c9341f214
commit
5c933a99a3
29 files changed
+2008
-110
No files matched your search
+3
-2
@@ -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>
|
||||
|
||||
+88
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¤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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user