Implement Android Home activity and HTTPS synchronization (#97)
This commit is contained in:
1 parent
8c9341f214
commit
5c933a99a3
29 files changed
+1991
-93
No files matched your search
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
# Native Android build
|
# Native Android build
|
||||||
|
|
||||||
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
|
||||||
[parity matrix](../docs/android/parity.md). The native foundation, 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
|
callbacks and build gates. Both variants invoke Rust through the generated
|
||||||
UniFFI API. Local-first and HTTPS onboarding is described in
|
UniFFI API. Local-first and HTTPS onboarding is described in
|
||||||
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
|
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
|
||||||
revision-checked [adaptive navigation](../docs/android/password-navigation.md);
|
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.
|
ARM-only runner/toolchain policy and artifact audits.
|
||||||
|
|
||||||
## Reproducible setup
|
## 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.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.rfc1437.ironstorage.core.HomeRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativeHomeRepository
|
||||||
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.NativePasswordRepository
|
import de.rfc1437.ironstorage.core.NativePasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.NativePrivateSession
|
import de.rfc1437.ironstorage.core.NativePrivateSession
|
||||||
@@ -23,6 +25,18 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object ShellModule {
|
object ShellModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun homeRepository(
|
||||||
|
session: NativePrivateSession,
|
||||||
|
passwords: PasswordRepository
|
||||||
|
): HomeRepository = NativeHomeRepository(
|
||||||
|
session,
|
||||||
|
passwords,
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
|
Dispatchers.IO.limitedParallelism(2)
|
||||||
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun passwordRepository(session: NativePrivateSession): PasswordRepository =
|
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),
|
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
val browsing = !snapshot.fixture && snapshot.nativeFailure == null &&
|
val ready = !snapshot.fixture && snapshot.nativeFailure == null &&
|
||||||
snapshot.storageRecovery == null && snapshot.state != ShellState.Empty &&
|
snapshot.storageRecovery == null &&
|
||||||
|
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
|
||||||
|
val browsing = ready &&
|
||||||
snapshot.selectedTab == ShellTab.Passwords
|
snapshot.selectedTab == ShellTab.Passwords
|
||||||
if (!browsing) {
|
val home = ready && snapshot.selectedTab == ShellTab.Home
|
||||||
|
if (!browsing && !home) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.app_name),
|
stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.headlineLarge
|
style = MaterialTheme.typography.headlineLarge
|
||||||
@@ -92,6 +95,8 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
|||||||
} else if (browsing) {
|
} else if (browsing) {
|
||||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
||||||
|
} else if (home) {
|
||||||
|
HomeScreen(viewModel.home, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.rfc1437.ironstorage.core.HomeRepository
|
||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
@@ -16,6 +17,7 @@ class ShellViewModel @Inject constructor(
|
|||||||
private val repository: ShellRepository,
|
private val repository: ShellRepository,
|
||||||
val onboarding: OnboardingRepository,
|
val onboarding: OnboardingRepository,
|
||||||
val passwords: PasswordRepository,
|
val passwords: PasswordRepository,
|
||||||
|
val home: HomeRepository,
|
||||||
private val saved: SavedStateHandle
|
private val saved: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val snapshot = repository.snapshot
|
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>
|
<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="app_name">IronStorage</string>
|
||||||
<string name="scaffold_title">No Password Store</string>
|
<string name="scaffold_title">No Password Store</string>
|
||||||
<string name="scaffold_detail">Your password store has not been configured.</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.MobileAuthenticationFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
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.MobileOnboardingErrorKind
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
|
||||||
@@ -21,6 +24,80 @@ import org.junit.Assume.assumeTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class AndroidOnboardingTest {
|
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() {
|
@Test fun privateNavigationTraversesWithoutAuthenticationAndRejectsReplacedEntries() {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
val id = "navigation-${UUID.randomUUID()}"
|
val id = "navigation-${UUID.randomUUID()}"
|
||||||
@@ -193,6 +270,17 @@ class AndroidOnboardingTest {
|
|||||||
assertFalse(config.contains("public-test-token"))
|
assertFalse(config.contains("public-test-token"))
|
||||||
assertFalse(config.contains("public-replacement-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]
|
#[uniffi::export]
|
||||||
impl MobileHomeOperation {
|
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 {
|
pub fn progress(&self) -> MobileHomeProgress {
|
||||||
let progress = self.operation.progress();
|
let progress = self.operation.progress();
|
||||||
MobileHomeProgress {
|
MobileHomeProgress {
|
||||||
@@ -569,6 +581,13 @@ impl MobileHomeOperation {
|
|||||||
self.operation.sync().map(Into::into).map_err(Into::into)
|
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) {
|
pub fn cancel(&self) {
|
||||||
self.operation.cancel();
|
self.operation.cancel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,22 @@ pub struct MobilePrivateStorageLayout {
|
|||||||
|
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
pub struct MobilePrivateRuntime {
|
pub struct MobilePrivateRuntime {
|
||||||
pub(crate) storage: PrivateStorage,
|
pub(crate) storage: Arc<PrivateStorage>,
|
||||||
}
|
}
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
impl MobilePrivateRuntime {
|
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(
|
pub fn android_onboarding(
|
||||||
&self,
|
&self,
|
||||||
backend: Arc<dyn MobileSecretBackend>,
|
backend: Arc<dyn MobileSecretBackend>,
|
||||||
@@ -163,30 +175,11 @@ impl MobilePrivateRuntime {
|
|||||||
backend: Arc<dyn MobileSecretBackend>,
|
backend: Arc<dyn MobileSecretBackend>,
|
||||||
clock: Arc<dyn MobileClock>,
|
clock: Arc<dyn MobileClock>,
|
||||||
) -> Result<Arc<MobileAuthentication>, crate::MobileAuthenticationFfiError> {
|
) -> Result<Arc<MobileAuthentication>, crate::MobileAuthenticationFfiError> {
|
||||||
self.storage
|
let authentication = self.storage.authentication(
|
||||||
.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(SecretBackendAdapter(backend)),
|
||||||
Arc::new(ClockAdapter(clock)),
|
Arc::new(ClockAdapter(clock)),
|
||||||
)?;
|
)?;
|
||||||
Ok(Arc::new(MobileAuthentication {
|
Ok(Arc::new(MobileAuthentication { authentication }))
|
||||||
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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -198,7 +191,9 @@ pub fn open_mobile_private_runtime(
|
|||||||
no_backup: directories.no_backup.into(),
|
no_backup: directories.no_backup.into(),
|
||||||
cache: directories.cache.into(),
|
cache: directories.cache.into(),
|
||||||
})?;
|
})?;
|
||||||
Ok(Arc::new(MobilePrivateRuntime { storage }))
|
Ok(Arc::new(MobilePrivateRuntime {
|
||||||
|
storage: Arc::new(storage),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::{
|
|||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
error::Error,
|
error::Error,
|
||||||
fmt,
|
fmt,
|
||||||
|
path::Path,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
@@ -79,7 +80,11 @@ impl MobileAuthenticationError {
|
|||||||
&self.detail
|
&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 {
|
Self {
|
||||||
kind,
|
kind,
|
||||||
title: title.to_owned(),
|
title: title.to_owned(),
|
||||||
@@ -352,6 +357,32 @@ pub struct MobileAuthentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub fn load() -> Result<Self, MobileAuthenticationError> {
|
||||||
let config = Config::load(None).map_err(|error| {
|
let config = Config::load(None).map_err(|error| {
|
||||||
MobileAuthenticationError::new(
|
MobileAuthenticationError::new(
|
||||||
@@ -913,6 +944,7 @@ impl MobileAuthentication {
|
|||||||
&self,
|
&self,
|
||||||
operation: &MobileTotpOperation,
|
operation: &MobileTotpOperation,
|
||||||
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||||
|
self.ensure_repository_idle()?;
|
||||||
self.ensure_active()?;
|
self.ensure_active()?;
|
||||||
let _discovery = self.totp_discovery.lock().map_err(|_| {
|
let _discovery = self.totp_discovery.lock().map_err(|_| {
|
||||||
entry_detail(
|
entry_detail(
|
||||||
@@ -946,6 +978,7 @@ impl MobileAuthentication {
|
|||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
|
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
|
||||||
|
self.ensure_repository_idle()?;
|
||||||
let shared = self.status()?.watch_shared_totp_entries.clone();
|
let shared = self.status()?.watch_shared_totp_entries.clone();
|
||||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||||
let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
|
let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use std::{
|
|||||||
error::Error,
|
error::Error,
|
||||||
fmt,
|
fmt,
|
||||||
path::Path,
|
path::Path,
|
||||||
sync::{Arc, Mutex},
|
sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,9 +24,11 @@ use crate::{
|
|||||||
mobile_authentication::{
|
mobile_authentication::{
|
||||||
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
||||||
},
|
},
|
||||||
|
mobile_private_storage::{PrivateStorage, PrivateStorageError, exclusive_lock},
|
||||||
repository::{Repository, RepositoryError},
|
repository::{Repository, RepositoryError},
|
||||||
secret_store::{
|
secret_store::{
|
||||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||||
|
SecretStoreBackend, SecretStoreError,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,6 +227,7 @@ impl MobileHomeNotice {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct MobileHomePage {
|
pub struct MobileHomePage {
|
||||||
|
activity_known: bool,
|
||||||
remote_configured: bool,
|
remote_configured: bool,
|
||||||
freshness: MobileHomeFreshness,
|
freshness: MobileHomeFreshness,
|
||||||
refreshed_at: Option<i64>,
|
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 {
|
pub enum MobileHomePhase {
|
||||||
Validating,
|
Validating,
|
||||||
Authenticating,
|
Authenticating,
|
||||||
@@ -321,27 +327,113 @@ impl MobileHomeProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct MobileHomeOperation {
|
pub struct MobileHomeOperation {
|
||||||
|
activity_known: AtomicBool,
|
||||||
authentication: Option<Arc<MobileAuthentication>>,
|
authentication: Option<Arc<MobileAuthentication>>,
|
||||||
control: GitOperationControl,
|
control: GitOperationControl,
|
||||||
phase: Arc<Mutex<MobileHomePhase>>,
|
phase: Arc<Mutex<MobileHomePhase>>,
|
||||||
action: Mutex<Option<MobileHomeActionKind>>,
|
action: Mutex<Option<MobileHomeActionKind>>,
|
||||||
|
private: Option<PrivateHomeContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivateHomeContext {
|
||||||
|
storage: Arc<PrivateStorage>,
|
||||||
|
backend: Arc<dyn SecretStoreBackend>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MobileHomeOperation {
|
impl MobileHomeOperation {
|
||||||
pub fn new(authentication: Option<Arc<MobileAuthentication>>) -> Self {
|
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 phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
|
||||||
let observed = Arc::clone(&phase);
|
let observed = Arc::clone(&phase);
|
||||||
|
let monotonic = private.is_some();
|
||||||
Self {
|
Self {
|
||||||
|
activity_known: AtomicBool::new(false),
|
||||||
|
private,
|
||||||
authentication,
|
authentication,
|
||||||
control: GitOperationControl::new(move |phase| {
|
control: GitOperationControl::new(move |phase| {
|
||||||
if let Ok(mut current) = observed.lock() {
|
if let Ok(mut current) = observed.lock() {
|
||||||
|
if monotonic {
|
||||||
|
if phase != GitProgressPhase::Refreshing {
|
||||||
|
*current = (*current).max(phase.into());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
*current = phase.into();
|
*current = phase.into();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
phase,
|
phase,
|
||||||
action: Mutex::new(None),
|
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) {
|
pub fn cancel(&self) {
|
||||||
self.control.cancel();
|
self.control.cancel();
|
||||||
}
|
}
|
||||||
@@ -356,9 +448,10 @@ impl MobileHomeOperation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn cached(&self) -> Result<MobileHomePage, MobileHomeError> {
|
pub fn cached(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
let storage = MobileHomeStorage::load()?;
|
let storage = self.load()?;
|
||||||
let refreshed_at = storage.config.mobile_home_refreshed_at();
|
let refreshed_at = storage.config.mobile_home_refreshed_at();
|
||||||
storage.page(
|
storage
|
||||||
|
.page(
|
||||||
if refreshed_at.is_some() {
|
if refreshed_at.is_some() {
|
||||||
MobileHomeFreshness::Cached
|
MobileHomeFreshness::Cached
|
||||||
} else {
|
} else {
|
||||||
@@ -367,20 +460,25 @@ impl MobileHomeOperation {
|
|||||||
refreshed_at,
|
refreshed_at,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
.map(|page| self.observe(page))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
let storage = MobileHomeStorage::load()?;
|
let storage = self.load()?;
|
||||||
let now = unix_seconds()?;
|
let now = unix_seconds()?;
|
||||||
if storage.remote.is_none() {
|
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) {
|
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
|
||||||
return storage.page(
|
return storage
|
||||||
|
.page(
|
||||||
MobileHomeFreshness::Cached,
|
MobileHomeFreshness::Cached,
|
||||||
storage.config.mobile_home_refreshed_at(),
|
storage.config.mobile_home_refreshed_at(),
|
||||||
None,
|
None,
|
||||||
);
|
)
|
||||||
|
.map(|page| self.observe(page));
|
||||||
}
|
}
|
||||||
self.run(MobileHomeActionKind::Fetch, || {
|
self.run(MobileHomeActionKind::Fetch, || {
|
||||||
storage.fetch(now, &self.control)
|
storage.fetch(now, &self.control)
|
||||||
@@ -392,35 +490,47 @@ impl MobileHomeOperation {
|
|||||||
return Err(MobileHomeError::invalid_commit_message());
|
return Err(MobileHomeError::invalid_commit_message());
|
||||||
}
|
}
|
||||||
self.run(MobileHomeActionKind::Commit, || {
|
self.run(MobileHomeActionKind::Commit, || {
|
||||||
MobileHomeStorage::load()?.commit(message, &self.control)
|
self.load()?.commit(message, &self.control)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
|
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
self.run(MobileHomeActionKind::Fetch, || {
|
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> {
|
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
self.run(MobileHomeActionKind::Pull, || {
|
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> {
|
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
self.run(MobileHomeActionKind::Push, || {
|
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> {
|
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
// Hold the pull reservation across both steps: sync can replace local entries.
|
// Hold the pull reservation across both steps: sync can replace local entries.
|
||||||
self.run(MobileHomeActionKind::Pull, || {
|
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(
|
fn run(
|
||||||
&self,
|
&self,
|
||||||
action: MobileHomeActionKind,
|
action: MobileHomeActionKind,
|
||||||
@@ -439,6 +549,16 @@ impl MobileHomeOperation {
|
|||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let result = operation();
|
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);
|
drop(reservation);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -448,20 +568,57 @@ struct MobileHomeStorage {
|
|||||||
config: Config,
|
config: Config,
|
||||||
git: GitRepository,
|
git: GitRepository,
|
||||||
remote: Option<GitRemote>,
|
remote: Option<GitRemote>,
|
||||||
|
backend: Option<Arc<dyn SecretStoreBackend>>,
|
||||||
|
_private_lock: Option<std::fs::File>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MobileHomeStorage {
|
impl MobileHomeStorage {
|
||||||
fn load() -> Result<Self, MobileHomeError> {
|
fn load(private: Option<&PrivateHomeContext>) -> Result<Self, MobileHomeError> {
|
||||||
let config = Config::load(None).map_err(MobileHomeError::from_config)?;
|
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 =
|
let repository =
|
||||||
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
||||||
let git = GitRepository::open(&repository, config.git_identity().clone())
|
let git = GitRepository::open(&repository, config.git_identity().clone())
|
||||||
.map_err(MobileHomeError::from_git)?;
|
.map_err(MobileHomeError::from_git)?;
|
||||||
let remote = config.git_remote(None).cloned();
|
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 {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
git,
|
git,
|
||||||
remote,
|
remote,
|
||||||
|
backend,
|
||||||
|
_private_lock: lock,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +636,7 @@ impl MobileHomeStorage {
|
|||||||
.map_err(MobileHomeError::from_git)?;
|
.map_err(MobileHomeError::from_git)?;
|
||||||
control
|
control
|
||||||
.report(GitProgressPhase::Refreshing)
|
.report(GitProgressPhase::Refreshing)
|
||||||
.map_err(MobileHomeError::from_git)?;
|
.map_err(|error| MobileHomeError::after_commit(MobileHomeError::from_git(error)))?;
|
||||||
self.page(
|
self.page(
|
||||||
MobileHomeFreshness::Cached,
|
MobileHomeFreshness::Cached,
|
||||||
self.config.mobile_home_refreshed_at(),
|
self.config.mobile_home_refreshed_at(),
|
||||||
@@ -492,15 +649,20 @@ impl MobileHomeStorage {
|
|||||||
&self,
|
&self,
|
||||||
now: i64,
|
now: i64,
|
||||||
control: &GitOperationControl,
|
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> {
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
let remote = self.remote()?;
|
let remote = self.remote()?;
|
||||||
let store = self.credentials()?;
|
let store = self.credentials()?;
|
||||||
let fetched = self.git.fetch_with_transport_controlled(
|
let fetched = self
|
||||||
remote,
|
.git
|
||||||
&store,
|
.fetch_with_transport_controlled(remote, &store, transport, control);
|
||||||
&crate::git::EmbeddedFetchTransport,
|
|
||||||
control,
|
|
||||||
);
|
|
||||||
let locked = store.lock();
|
let locked = store.lock();
|
||||||
let outcome = fetched.map_err(MobileHomeError::from_git)?;
|
let outcome = fetched.map_err(MobileHomeError::from_git)?;
|
||||||
if let Err(error) = locked {
|
if let Err(error) = locked {
|
||||||
@@ -508,7 +670,7 @@ impl MobileHomeStorage {
|
|||||||
}
|
}
|
||||||
control
|
control
|
||||||
.report(GitProgressPhase::Refreshing)
|
.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)))
|
self.current_page(now, Some(fetch_notice(&outcome)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,16 +678,20 @@ impl MobileHomeStorage {
|
|||||||
&self,
|
&self,
|
||||||
now: i64,
|
now: i64,
|
||||||
control: &GitOperationControl,
|
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> {
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
let remote = self.remote()?;
|
let remote = self.remote()?;
|
||||||
let store = self.credentials()?;
|
let store = self.credentials()?;
|
||||||
let pulled = self.git.pull_with_transport_controlled(
|
let pulled = self
|
||||||
remote,
|
.git
|
||||||
None,
|
.pull_with_transport_controlled(remote, None, &store, transport, control);
|
||||||
&store,
|
|
||||||
&crate::git::EmbeddedFetchTransport,
|
|
||||||
control,
|
|
||||||
);
|
|
||||||
let locked = store.lock();
|
let locked = store.lock();
|
||||||
let outcome = pulled.map_err(MobileHomeError::from_git)?;
|
let outcome = pulled.map_err(MobileHomeError::from_git)?;
|
||||||
if let Err(error) = locked {
|
if let Err(error) = locked {
|
||||||
@@ -533,7 +699,7 @@ impl MobileHomeStorage {
|
|||||||
}
|
}
|
||||||
control
|
control
|
||||||
.report(GitProgressPhase::Refreshing)
|
.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)))
|
self.current_page(now, Some(pull_notice(outcome)))
|
||||||
.map_err(MobileHomeError::after_pull)
|
.map_err(MobileHomeError::after_pull)
|
||||||
}
|
}
|
||||||
@@ -542,16 +708,20 @@ impl MobileHomeStorage {
|
|||||||
&self,
|
&self,
|
||||||
now: i64,
|
now: i64,
|
||||||
control: &GitOperationControl,
|
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> {
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
let remote = self.remote()?;
|
let remote = self.remote()?;
|
||||||
let store = self.credentials()?;
|
let store = self.credentials()?;
|
||||||
let pushed = self.git.push_with_transport_controlled(
|
let pushed = self
|
||||||
remote,
|
.git
|
||||||
None,
|
.push_with_transport_controlled(remote, None, &store, transport, control);
|
||||||
&store,
|
|
||||||
&crate::git::ReqwestGitTransport,
|
|
||||||
control,
|
|
||||||
);
|
|
||||||
let locked = store.lock();
|
let locked = store.lock();
|
||||||
let outcome = pushed.map_err(MobileHomeError::from_git)?;
|
let outcome = pushed.map_err(MobileHomeError::from_git)?;
|
||||||
if let Err(error) = locked {
|
if let Err(error) = locked {
|
||||||
@@ -559,7 +729,7 @@ impl MobileHomeStorage {
|
|||||||
}
|
}
|
||||||
control
|
control
|
||||||
.report(GitProgressPhase::Refreshing)
|
.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)))
|
self.current_page(now, Some(push_notice(&outcome)))
|
||||||
.map_err(MobileHomeError::after_push)
|
.map_err(MobileHomeError::after_push)
|
||||||
}
|
}
|
||||||
@@ -577,7 +747,7 @@ impl MobileHomeStorage {
|
|||||||
locked.map_err(MobileHomeError::partial_secret)?;
|
locked.map_err(MobileHomeError::partial_secret)?;
|
||||||
control
|
control
|
||||||
.report(GitProgressPhase::Refreshing)
|
.report(GitProgressPhase::Refreshing)
|
||||||
.map_err(MobileHomeError::from_git)?;
|
.map_err(|error| MobileHomeError::after_push(MobileHomeError::from_git(error)))?;
|
||||||
let mut notice = push_notice(&pushed);
|
let mut notice = push_notice(&pushed);
|
||||||
notice.title = "Sync Completed".to_owned();
|
notice.title = "Sync Completed".to_owned();
|
||||||
notice.detail = format!("Pull completed successfully. {}", notice.detail);
|
notice.detail = format!("Pull completed successfully. {}", notice.detail);
|
||||||
@@ -585,12 +755,16 @@ impl MobileHomeStorage {
|
|||||||
.map_err(MobileHomeError::after_push)
|
.map_err(MobileHomeError::after_push)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
|
fn credentials(&self) -> Result<SecretStore<Arc<dyn SecretStoreBackend>>, MobileHomeError> {
|
||||||
let store = NativeSecretStore::system(
|
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,
|
SecretCachePolicy::Disabled,
|
||||||
SecretProtectionPolicy::device_unlocked(),
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
)
|
);
|
||||||
.map_err(MobileHomeError::from_secret)?;
|
|
||||||
store.unlock().map_err(MobileHomeError::from_secret)?;
|
store.unlock().map_err(MobileHomeError::from_secret)?;
|
||||||
Ok(store)
|
Ok(store)
|
||||||
}
|
}
|
||||||
@@ -630,29 +804,90 @@ impl MobileHomeStorage {
|
|||||||
refreshed_at: Option<i64>,
|
refreshed_at: Option<i64>,
|
||||||
notice: Option<MobileHomeNotice>,
|
notice: Option<MobileHomeNotice>,
|
||||||
) -> Result<MobileHomePage, MobileHomeError> {
|
) -> Result<MobileHomePage, MobileHomeError> {
|
||||||
if let Some(remote) = self.remote.as_ref() {
|
let mut page = if let Some(remote) = self.remote.as_ref() {
|
||||||
let divergence = self
|
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
|
.git
|
||||||
.divergence(remote, ACTIVITY_LIMIT)
|
.snapshot(None, ACTIVITY_LIMIT)
|
||||||
.map_err(MobileHomeError::from_git)?;
|
.map_err(MobileHomeError::from_git)?;
|
||||||
Ok(page_from_divergence(
|
let mut page =
|
||||||
&divergence,
|
page_from_local_snapshot(&snapshot, freshness, refreshed_at, notice);
|
||||||
freshness,
|
page.remote_configured = true;
|
||||||
refreshed_at,
|
page.summaries[0] = MobileHomeSummaryRow {
|
||||||
notice,
|
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 {
|
} else {
|
||||||
let snapshot = self
|
let snapshot = self
|
||||||
.git
|
.git
|
||||||
.snapshot(None, ACTIVITY_LIMIT)
|
.snapshot(None, ACTIVITY_LIMIT)
|
||||||
.map_err(MobileHomeError::from_git)?;
|
.map_err(MobileHomeError::from_git)?;
|
||||||
Ok(page_from_local_snapshot(
|
page_from_local_snapshot(&snapshot, freshness, refreshed_at, notice)
|
||||||
&snapshot,
|
};
|
||||||
freshness,
|
if self.backend.is_some() {
|
||||||
refreshed_at,
|
let status = self.git.status().map_err(MobileHomeError::from_git)?;
|
||||||
notice,
|
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 {
|
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 {
|
pub fn kind(&self) -> MobileHomeErrorKind {
|
||||||
self.kind
|
self.kind
|
||||||
}
|
}
|
||||||
@@ -781,6 +1027,11 @@ impl MobileHomeError {
|
|||||||
detail: "The HTTPS server identity could not be verified. Cached activity was not replaced."
|
detail: "The HTTPS server identity could not be verified. Cached activity was not replaced."
|
||||||
.to_owned(),
|
.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 {
|
GitError::Cancelled => Self {
|
||||||
kind: MobileHomeErrorKind::Interrupted,
|
kind: MobileHomeErrorKind::Interrupted,
|
||||||
title: "Refresh Interrupted",
|
title: "Refresh Interrupted",
|
||||||
@@ -793,6 +1044,11 @@ impl MobileHomeError {
|
|||||||
detail: "The configured credential-free HTTPS remote does not match the local clone."
|
detail: "The configured credential-free HTTPS remote does not match the local clone."
|
||||||
.to_owned(),
|
.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 {
|
_ => Self {
|
||||||
kind: MobileHomeErrorKind::Repository,
|
kind: MobileHomeErrorKind::Repository,
|
||||||
title: "Remote Activity Is Unavailable",
|
title: "Remote Activity Is Unavailable",
|
||||||
@@ -989,6 +1245,7 @@ fn page_from_divergence(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
MobileHomePage {
|
MobileHomePage {
|
||||||
|
activity_known: true,
|
||||||
remote_configured: true,
|
remote_configured: true,
|
||||||
freshness,
|
freshness,
|
||||||
refreshed_at,
|
refreshed_at,
|
||||||
@@ -1034,6 +1291,7 @@ fn page_from_local_snapshot(
|
|||||||
})
|
})
|
||||||
.map(|line| display_text(line.trim(), 160));
|
.map(|line| display_text(line.trim(), 160));
|
||||||
MobileHomePage {
|
MobileHomePage {
|
||||||
|
activity_known: false,
|
||||||
remote_configured: false,
|
remote_configured: false,
|
||||||
freshness,
|
freshness,
|
||||||
refreshed_at,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::path::Path;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
pub(crate) mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
git::{GitFetchTransport, GitRemoteCredentialProvider},
|
git::{GitFetchTransport, GitRemoteCredentialProvider},
|
||||||
@@ -909,7 +909,7 @@ mod tests {
|
|||||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
|
||||||
}
|
}
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Backend {
|
pub(crate) struct Backend {
|
||||||
records: Mutex<BTreeMap<SecretLocator, Vec<u8>>>,
|
records: Mutex<BTreeMap<SecretLocator, Vec<u8>>>,
|
||||||
fail_tokens: AtomicBool,
|
fail_tokens: AtomicBool,
|
||||||
}
|
}
|
||||||
@@ -1510,4 +1510,197 @@ mod tests {
|
|||||||
b"retained password store"
|
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::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
mobile::MobileShell,
|
mobile::MobileShell,
|
||||||
|
mobile_authentication::{
|
||||||
|
MobileAuthentication, MobileAuthenticationError, MobileAuthenticationErrorKind,
|
||||||
|
},
|
||||||
secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError},
|
secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError},
|
||||||
};
|
};
|
||||||
use cap_std::{
|
use cap_std::{
|
||||||
@@ -17,7 +20,7 @@ use std::{
|
|||||||
fmt, fs,
|
fmt, fs,
|
||||||
io::{self, Read, Write},
|
io::{self, Read, Write},
|
||||||
path::{Component, Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
sync::Mutex,
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERSION: u32 = 1;
|
const VERSION: u32 = 1;
|
||||||
@@ -136,6 +139,7 @@ pub struct PrivateStorage {
|
|||||||
data: Dir,
|
data: Dir,
|
||||||
cache: Dir,
|
cache: Dir,
|
||||||
status: Mutex<PrivateStorageStatus>,
|
status: Mutex<PrivateStorageStatus>,
|
||||||
|
authentication: Mutex<Option<Arc<MobileAuthentication>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrivateStorage {
|
impl PrivateStorage {
|
||||||
@@ -228,6 +232,7 @@ impl PrivateStorage {
|
|||||||
});
|
});
|
||||||
let root = roots.no_backup.join("ironstorage");
|
let root = roots.no_backup.join("ironstorage");
|
||||||
let mut storage = Self {
|
let mut storage = Self {
|
||||||
|
authentication: Mutex::new(None),
|
||||||
root,
|
root,
|
||||||
cache_path: roots.cache.join("ironstorage"),
|
cache_path: roots.cache.join("ironstorage"),
|
||||||
data,
|
data,
|
||||||
@@ -286,6 +291,39 @@ impl PrivateStorage {
|
|||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&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.
|
/// Every existing default-path API now uses this explicit OS context, never HOME/cwd.
|
||||||
pub fn activate(&self) -> Result<(), PrivateStorageError> {
|
pub fn activate(&self) -> Result<(), PrivateStorageError> {
|
||||||
if crate::config::ConfigLoader::activate_private_root(&self.root) {
|
if crate::config::ConfigLoader::activate_private_root(&self.root) {
|
||||||
|
|||||||
@@ -439,7 +439,11 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
.entries
|
.entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, record)| record.is_totp)
|
.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))
|
.filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query))
|
||||||
.map(|path| cached_row(&path, shared.contains(&path)))
|
.map(|path| cached_row(&path, shared.contains(&path)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -274,11 +274,30 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
|||||||
);
|
);
|
||||||
|
|
||||||
repository.remove_entry(&EntryPath::parse("otp/alice")?)?;
|
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 deleted = MobileTotpOperation::default();
|
||||||
let mut deleted_secrets = CountingSecrets::all(&fixture);
|
let mut deleted_secrets = CountingSecrets::all(&fixture);
|
||||||
let deleted_page =
|
let deleted_page =
|
||||||
service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?;
|
service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?;
|
||||||
assert_eq!(deleted_secrets.requests, 0);
|
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!(
|
assert!(
|
||||||
deleted_page
|
deleted_page
|
||||||
.rows()
|
.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