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

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

No files matched your search

+3 -2
View File
@@ -1,12 +1,13 @@
# Native Android build
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
[parity matrix](../docs/android/parity.md). The native foundation, onboarding and navigation implement #89–#96, including private storage, Keystore
[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation and Home implement #89–#97, including private storage, Keystore
callbacks and build gates. Both variants invoke Rust through the generated
UniFFI API. Local-first and HTTPS onboarding is described in
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
revision-checked [adaptive navigation](../docs/android/password-navigation.md);
remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md).
Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
ARM-only runner/toolchain policy and artifact audits.
## Reproducible setup
@@ -0,0 +1,188 @@
package de.rfc1437.ironstorage
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import de.rfc1437.ironstorage.core.HomeAction
import de.rfc1437.ironstorage.core.HomeActionKind
import de.rfc1437.ironstorage.core.HomeChange
import de.rfc1437.ironstorage.core.HomeCommit
import de.rfc1437.ironstorage.core.HomeFailure
import de.rfc1437.ironstorage.core.HomeFailureKind
import de.rfc1437.ironstorage.core.HomeFreshness
import de.rfc1437.ironstorage.core.HomeNotice
import de.rfc1437.ironstorage.core.HomePage
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.HomeRow
import de.rfc1437.ironstorage.core.HomeState
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.HomeScreen
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class HomeScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private fun show(repository: Fixture) {
compose.activity.runOnUiThread {
compose.activity.setContent { MaterialTheme { HomeScreen(repository) } }
}
}
@Test fun typedActionsUseTheConfiguredIdentityAndNeverReplayAfterReload() {
val repository = Fixture()
show(repository)
compose.onNodeWithText("Cached activity").assertIsDisplayed()
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
compose.onNodeWithText("Android fixture").assertIsDisplayed()
compose.onNodeWithText("fixture@example.test").assertIsDisplayed()
compose.onNodeWithText("Commit message").performTextInput("Commit encrypted objects")
compose.onNodeWithText("Create commit").performClick()
assertEquals(
HomeActionKind.Commit to "Commit encrypted objects",
repository.requests.single()
)
compose.onNodeWithText("Cancel").performClick()
for (action in listOf(HomeActionKind.Fetch, HomeActionKind.Pull, HomeActionKind.Push)) {
compose.onNodeWithText(action.name).performScrollTo().performClick()
assertEquals(action, repository.requests.last().first)
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText("Reload status").performClick()
}
val before = repository.requests.size
show(repository)
compose.waitForIdle()
assertEquals(before, repository.requests.size)
assertTrue(repository.entries > 0)
}
@Test fun slowOperationsLeaveScrollingAndCancellationResponsiveWithLargeHistory() {
val repository = Fixture(1000)
show(repository)
compose.onNodeWithText("Fetch").performScrollTo().performClick()
compose.onNodeWithText("Receiving remote objects").assertIsDisplayed()
compose.onNodeWithText("Fetch").performScrollTo().assertIsNotEnabled()
compose.onNode(hasScrollAction()).performScrollToNode(hasText("object-999"))
compose.onNodeWithText("object-999").assertIsDisplayed()
compose.onNodeWithText("Cancel").performClick()
compose.onNode(hasScrollAction()).performScrollToNode(hasText("Refresh Interrupted"))
compose.onNodeWithText("Refresh Interrupted").assertIsDisplayed()
assertEquals(1, repository.cancellations)
}
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
val repository = Fixture()
show(repository)
compose.onRoot().performTouchInput { swipeDown() }
compose.waitUntil(5000) { repository.refreshes == 1 }
assertTrue(repository.requests.isEmpty())
compose.onNodeWithText("Cancel").performClick()
}
private class Fixture(changes: Int = 1) : HomeRepository {
private val actions = HomeActionKind.entries.map {
HomeAction(
it,
it.name,
when (it) {
HomeActionKind.Commit -> ShellIcon.Success
HomeActionKind.Fetch -> ShellIcon.Refresh
HomeActionKind.Pull -> ShellIcon.Incoming
HomeActionKind.Push -> ShellIcon.Outgoing
}
)
}
override val state = MutableStateFlow(
HomeState(
page = HomePage(
HomeFreshness.Cached,
1234567890,
listOf(
HomeRow(
"tracked",
"origin/main",
"Tracked HTTPS branch",
ShellIcon.Branch,
actions
)
),
listOf(
HomeCommit(
"incoming-id",
"Incoming encrypted objects",
"Fixture author",
1234567890,
(0 until changes).map {
HomeChange(
"change-$it",
"object-$it",
"Added password entry",
ShellIcon.Key
)
},
emptyList()
)
),
emptyList(),
1u,
0u,
null
),
name = "Android fixture",
email = "fixture@example.test"
)
)
val requests = mutableListOf<Pair<HomeActionKind, String>>()
var cancellations = 0
var entries = 0
var refreshes = 0
override fun enter() {
entries++
}
override fun refresh() {
refreshes++
busy()
}
override fun perform(action: HomeActionKind, message: String) {
requests += action to message
busy()
}
private fun busy() {
state.value = state.value.copy(
busy = true,
failure = null,
progress = HomeNotice("Receiving remote objects", "Slow fixture network")
)
}
override fun reload() {
state.value = state.value.copy(failure = null)
}
override fun cancel() {
cancellations++
state.value = state.value.copy(
busy = false,
progress = null,
failure = HomeFailure(
HomeFailureKind.Interrupted,
"Refresh Interrupted",
"Reload cached activity before retrying."
)
)
}
override fun leave() = Unit
}
}
@@ -6,7 +6,9 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.NativeHomeRepository
import de.rfc1437.ironstorage.core.NativeOnboardingRepository
import de.rfc1437.ironstorage.core.NativePasswordRepository
import de.rfc1437.ironstorage.core.NativePrivateSession
@@ -23,6 +25,18 @@ import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object ShellModule {
@Provides
@Singleton
fun homeRepository(
session: NativePrivateSession,
passwords: PasswordRepository
): HomeRepository = NativeHomeRepository(
session,
passwords,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
Dispatchers.IO.limitedParallelism(2)
)
@Provides
@Singleton
fun passwordRepository(session: NativePrivateSession): PasswordRepository =
@@ -0,0 +1,66 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class HomeActionKind { Commit, Fetch, Pull, Push }
enum class HomeFreshness { NeverRefreshed, Cached, Current }
enum class HomeFailureKind {
MissingConfiguration,
Configuration,
Authentication,
Conflict,
DirtyLocalChanges,
NoChanges,
Offline,
Interrupted,
SecureStorage,
Repository,
PartialProgress
}
data class HomeAction(val kind: HomeActionKind, val title: String, val icon: ShellIcon)
data class HomeRow(
val id: String,
val title: String,
val detail: String,
val icon: ShellIcon,
val actions: List<HomeAction>
)
data class HomeChange(val id: String, val title: String, val detail: String, val icon: ShellIcon)
data class HomeCommit(
val id: String,
val title: String,
val detail: String,
val timestamp: Long,
val changes: List<HomeChange>,
val actions: List<HomeAction>
)
data class HomeNotice(val title: String, val detail: String)
data class HomePage(
val freshness: HomeFreshness,
val refreshedAt: Long?,
val summaries: List<HomeRow>,
val incoming: List<HomeCommit>,
val outgoing: List<HomeCommit>,
val incomingTotal: UInt,
val outgoingTotal: UInt,
val notice: HomeNotice?,
val activityKnown: Boolean = true
)
data class HomeFailure(val kind: HomeFailureKind, val title: String, val detail: String)
data class HomeState(
val page: HomePage? = null,
val name: String = "",
val email: String = "",
val busy: Boolean = false,
val progress: HomeNotice? = null,
val failure: HomeFailure? = null
)
interface HomeRepository {
val state: StateFlow<HomeState>
fun enter()
fun refresh()
fun perform(action: HomeActionKind, message: String = "")
fun reload()
fun cancel()
fun leave()
}
@@ -0,0 +1,203 @@
package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileHomeActionKind
import de.rfc1437.ironstorage.bridge.generated.MobileHomeErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileHomeFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileHomeFreshness
import de.rfc1437.ironstorage.bridge.generated.MobileHomeOperation
import de.rfc1437.ironstorage.bridge.generated.MobileHomePage
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeHomeRepository(
private val session: NativePrivateSession,
private val passwords: PasswordRepository,
private val scope: CoroutineScope,
private val io: CoroutineDispatcher
) : HomeRepository {
private val mutable = MutableStateFlow(HomeState())
override val state: StateFlow<HomeState> = mutable
private val sequence = AtomicLong()
private val mutex = Mutex()
@Volatile private var active: MobileHomeOperation? = null
private var cancelTicket: Long? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
override fun enter() = run(Request.Enter)
override fun reload() = run(Request.Enter)
override fun refresh() = run(Request.Refresh)
override fun perform(action: HomeActionKind, message: String) = run(
when (action) {
HomeActionKind.Commit -> Request.Commit
HomeActionKind.Fetch -> Request.Fetch
HomeActionKind.Pull -> Request.Pull
HomeActionKind.Push -> Request.Push
},
message
)
override fun cancel() {
cancelTicket = sequence.get()
val operation = active
scope.launch(io) { operation?.cancel() }
}
override fun leave() {
sequence.incrementAndGet()
val operation = active
scope.launch(io) { operation?.cancel() }
mutable.value = mutable.value.copy(busy = false, progress = null)
}
private enum class Request { Enter, Refresh, Commit, Fetch, Pull, Push }
private fun run(request: Request, message: String = "") {
if (mutable.value.busy) return
val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(busy = true, failure = null, progress = null)
scope.launch {
mutex.withLock {
if (sequence.get() != ticket) return@withLock
val polling = launch {
while (sequence.get() == ticket) {
delay(150)
val progress = withContext(io) { active?.progress() }
if (sequence.get() == ticket && progress != null) {
mutable.value = mutable.value.copy(
progress = HomeNotice(progress.title, progress.detail)
)
}
}
}
try {
val current = withContext(io) {
session.storage().androidHomeOperation(session.backend(), clock)
}
active = current
if (sequence.get() != ticket || cancelTicket == ticket) {
withContext(io) { current.cancel() }
}
val identity = withContext(io) { current.identity() }
val cached = withContext(io) {
current.cached().toFacade(current.activityKnown())
}
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(
page = cached,
name = identity.name,
email = identity.email
)
}
val page = withContext(io) {
when (request) {
Request.Enter -> current.refreshIfStale()
Request.Refresh -> current.refreshGesture()
Request.Commit -> current.commit(message)
Request.Fetch -> current.fetch()
Request.Pull -> current.pull()
Request.Push -> current.push()
}.toFacade(current.activityKnown())
}
if (sequence.get() == ticket) {
mutable.value = HomeState(page, identity.name, identity.email)
if (request == Request.Pull || request == Request.Refresh) {
passwords.refresh()
}
}
} catch (error: MobileHomeFfiException.Failed) {
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(
failure = HomeFailure(error.kind.toFacade(), error.title, error.detail)
)
}
} catch (_: MobilePrivateStorageFfiException) {
failure(
ticket,
"Unlock the device or recover private storage, then reload Home."
)
} catch (_: NativeBridgeException) {
failure(ticket, "Update the app and reload Home.")
} catch (_: java.io.IOException) {
failure(
ticket,
"Private storage is unavailable. Unlock the device and reload Home."
)
} finally {
active = null
polling.cancelAndJoin()
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(busy = false, progress = null)
}
}
}
}
}
private fun failure(ticket: Long, detail: String) {
if (sequence.get() == ticket) {
mutable.value = mutable.value.copy(
failure = HomeFailure(HomeFailureKind.Repository, "Home is unavailable", detail)
)
}
}
}
private fun MobileHomeActionKind.toFacade(): HomeActionKind = when (this) {
MobileHomeActionKind.COMMIT -> HomeActionKind.Commit
MobileHomeActionKind.FETCH -> HomeActionKind.Fetch
MobileHomeActionKind.PULL -> HomeActionKind.Pull
MobileHomeActionKind.PUSH -> HomeActionKind.Push
}
private fun MobileHomeErrorKind.toFacade(): HomeFailureKind = when (this) {
MobileHomeErrorKind.MISSING_CONFIGURATION -> HomeFailureKind.MissingConfiguration
MobileHomeErrorKind.CONFIGURATION -> HomeFailureKind.Configuration
MobileHomeErrorKind.AUTHENTICATION -> HomeFailureKind.Authentication
MobileHomeErrorKind.CONFLICT -> HomeFailureKind.Conflict
MobileHomeErrorKind.DIRTY_LOCAL_CHANGES -> HomeFailureKind.DirtyLocalChanges
MobileHomeErrorKind.NO_CHANGES -> HomeFailureKind.NoChanges
MobileHomeErrorKind.OFFLINE -> HomeFailureKind.Offline
MobileHomeErrorKind.INTERRUPTED -> HomeFailureKind.Interrupted
MobileHomeErrorKind.SECURE_STORAGE -> HomeFailureKind.SecureStorage
MobileHomeErrorKind.REPOSITORY -> HomeFailureKind.Repository
MobileHomeErrorKind.PARTIAL_PROGRESS -> HomeFailureKind.PartialProgress
}
private fun MobileHomePage.toFacade(activityKnown: Boolean): HomePage {
fun action(value: de.rfc1437.ironstorage.bridge.generated.MobileHomeAction): HomeAction =
HomeAction(value.kind.toFacade(), value.title, value.icon.toFacade())
fun commit(value: de.rfc1437.ironstorage.bridge.generated.MobileHomeCommit): HomeCommit =
HomeCommit(
value.id,
value.title,
value.detail,
value.timestamp,
value.changes.map { HomeChange(it.id, it.title, it.detail, it.icon.toFacade()) },
value.actions.map(::action)
)
return HomePage(
when (freshness) {
MobileHomeFreshness.NEVER_REFRESHED -> HomeFreshness.NeverRefreshed
MobileHomeFreshness.CACHED -> HomeFreshness.Cached
MobileHomeFreshness.CURRENT -> HomeFreshness.Current
},
refreshedAt,
summaries.map {
HomeRow(it.id, it.title, it.detail, it.icon.toFacade(), it.actions.map(::action))
},
incoming.map(::commit),
outgoing.map(::commit),
incomingTotal,
outgoingTotal,
notice?.let { HomeNotice(it.title, it.detail) },
activityKnown
)
}
@@ -0,0 +1,281 @@
package de.rfc1437.ironstorage.features
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.HomeAction
import de.rfc1437.ironstorage.core.HomeActionKind
import de.rfc1437.ironstorage.core.HomeFreshness
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.ShellIcon
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
val state by repository.state.collectAsStateWithLifecycle()
var committing by remember { mutableStateOf(false) }
var message by remember { mutableStateOf("") }
DisposableEffect(repository) {
repository.enter()
onDispose { repository.leave() }
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
committing = false
message = ""
repository.leave()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.enter() }
fun perform(action: HomeActionKind) {
if (action == HomeActionKind.Commit) {
committing = true
} else {
repository.perform(action)
}
}
Column(modifier) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = !state.busy, onClick = repository::refresh) {
Text(stringResource(R.string.home_refresh))
}
if (state.busy) {
TextButton(onClick = repository::cancel) {
Text(stringResource(R.string.home_cancel))
}
Text(
state.progress?.title ?: stringResource(R.string.home_loading),
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
)
}
}
PullToRefreshBox(
isRefreshing = state.busy,
onRefresh = { if (!state.busy) repository.refresh() },
modifier = Modifier.weight(1f)
) {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
Text(
stringResource(R.string.home_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() }
)
}
if (state.busy) {
item {
Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
CircularProgressIndicator()
state.progress?.let {
Text(it.detail)
}
}
}
}
state.failure?.let { failure ->
item {
Text(failure.title, color = MaterialTheme.colorScheme.error)
Text(failure.detail)
TextButton(enabled = !state.busy, onClick = repository::reload) {
Text(stringResource(R.string.home_reload))
}
}
}
val page = state.page
if (page != null) {
item {
Text(
stringResource(
when (page.freshness) {
HomeFreshness.NeverRefreshed -> R.string.home_never
HomeFreshness.Cached -> R.string.home_cached
HomeFreshness.Current -> R.string.home_current
}
)
)
page.refreshedAt?.let { timestamp ->
val formatted = DateUtils.formatDateTime(
LocalContext.current,
timestamp * 1000,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME
)
Text(stringResource(R.string.home_checked, formatted))
}
page.notice?.let {
Text(it.title, style = MaterialTheme.typography.titleMedium)
Text(it.detail)
}
}
items(page.summaries, key = { it.id }) { row ->
Column {
ListItem(
headlineContent = { Text(row.title) },
supportingContent = { Text(row.detail) },
leadingContent = { HomeIcon(row.icon) }
)
HomeActions(row.actions, state.busy, ::perform)
}
}
if (page.activityKnown) {
listOf(
Triple(R.string.home_incoming, page.incomingTotal, page.incoming),
Triple(R.string.home_outgoing, page.outgoingTotal, page.outgoing)
).forEach { (label, total, commits) ->
item(key = "activity:$label") {
Text(
stringResource(label, total.toString()),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.semantics { heading() }
)
if (total == 0u) Text(stringResource(R.string.home_no_activity))
}
commits.forEach { commit ->
item(key = "$label:${commit.id}") {
Text(commit.title, style = MaterialTheme.typography.titleMedium)
Text(commit.detail)
val time = DateUtils.formatDateTime(
LocalContext.current,
commit.timestamp * 1000,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME
)
Text(time)
HomeActions(commit.actions, state.busy, ::perform)
}
items(
commit.changes,
key = { "$label:${commit.id}:${it.id}" }
) { change ->
ListItem(
headlineContent = { Text(change.title) },
supportingContent = { Text(change.detail) },
leadingContent = { HomeIcon(change.icon) }
)
}
}
if (total > commits.size.toUInt()) {
item {
Text(
pluralStringResource(
R.plurals.home_bounded,
commits.size,
commits.size
)
)
}
}
}
}
}
}
}
}
if (committing) {
AlertDialog(
onDismissRequest = {
committing = false
message = ""
},
title = { Text(stringResource(R.string.home_commit_title)) },
text = {
Column {
Text(state.name)
Text(state.email)
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text(stringResource(R.string.home_commit_message)) }
)
}
},
confirmButton = {
TextButton(enabled = !state.busy, onClick = {
val submitted = message
message = ""
committing = false
repository.perform(HomeActionKind.Commit, submitted)
}) { Text(stringResource(R.string.home_commit_submit)) }
},
dismissButton = {
TextButton(onClick = {
committing = false
message = ""
}) {
Text(stringResource(R.string.home_cancel))
}
}
)
}
}
@Composable
private fun HomeActions(
actions: List<HomeAction>,
busy: Boolean,
perform: (HomeActionKind) -> Unit
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { action ->
TextButton(enabled = !busy, onClick = { perform(action.kind) }) {
HomeIcon(action.icon)
Text(action.title, Modifier.padding(start = 8.dp))
}
}
}
}
@Composable
private fun HomeIcon(icon: ShellIcon) {
Icon(
painterResource(
when (icon) {
ShellIcon.Key -> R.drawable.ic_key
ShellIcon.Warning -> R.drawable.ic_warning
ShellIcon.Incoming -> R.drawable.ic_incoming
ShellIcon.Outgoing -> R.drawable.ic_outgoing
ShellIcon.Success -> R.drawable.ic_success
ShellIcon.Refresh, ShellIcon.Sync -> R.drawable.ic_refresh
ShellIcon.Branch, ShellIcon.Merge -> R.drawable.ic_branch
ShellIcon.Protected -> R.drawable.ic_protected
ShellIcon.History -> R.drawable.ic_history
ShellIcon.Repository -> R.drawable.ic_folder
ShellIcon.Recipients -> R.drawable.ic_settings
ShellIcon.Signature, ShellIcon.File -> R.drawable.ic_file
else -> error("Invalid Rust Home icon")
}
),
null
)
}
@@ -53,10 +53,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val browsing = !snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null && snapshot.state != ShellState.Empty &&
val ready = !snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
val browsing = ready &&
snapshot.selectedTab == ShellTab.Passwords
if (!browsing) {
val home = ready && snapshot.selectedTab == ShellTab.Home
if (!browsing && !home) {
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge
@@ -92,6 +95,8 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
} else if (browsing) {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
} else if (home) {
HomeScreen(viewModel.home, Modifier.weight(1f))
} else {
Text(
title,
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellRepository
@@ -16,6 +17,7 @@ class ShellViewModel @Inject constructor(
private val repository: ShellRepository,
val onboarding: OnboardingRepository,
val passwords: PasswordRepository,
val home: HomeRepository,
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M6,2a3,3 0,1 0,0 6v8a3,3 0,1 0,2 0V12h8a4,4 0,0 0,4 -4V7a3,3 0,1 0,-2 0v1a2,2 0,0 1,-2 2H8V8a3,3 0,0 0,-2 -6z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M6,2h9l5,5v15H4V2z M14,4v5h5z M7,12v2h10v-2z M7,16v2h10v-2z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M13,3a9,9 0,1 1,-8.6 12h2.2A7,7 0,1 0,6.4 8H10v2H2V2h2v4A9,9 0,0 1,13 3z M12,7h2v5l3,2 -1,1.6 -4,-2.6z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M11,3h2v12l4,-4 1.4,1.4L12,18 5.6,12.4 7,11l4,4z M5,20h14v2H5z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M11,21h2V9l4,4 1.4,-1.4L12,6 5.6,11.6 7,13l4,-4z M5,2h14v2H5z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M12,2L3,6v6c0,5 4,9 9,10 5,-1 9,-5 9,-10V6z M10,16l-4,-4 1.4,-1.4 2.6,2.6 6.6,-6.6L18,8z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8s3.58,8 8,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,.69 4.22,1.78L13,11h7V4z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M9,16.2L4.8,12 3.4,13.4 9,19 21,7 19.6,5.6z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M1,21h22L12,2 1,21z M13,18h-2v-2h2v2z M13,14h-2v-4h2v4z" />
</vector>
@@ -1,4 +1,23 @@
<resources>
<string name="home_title">Remote activity</string>
<string name="home_loading">Loading Home</string>
<string name="home_cancel">Cancel</string>
<string name="home_refresh">Refresh and sync</string>
<string name="home_reload">Reload status</string>
<string name="home_never">Remote status has not been refreshed</string>
<string name="home_cached">Cached activity</string>
<string name="home_current">Current activity</string>
<string name="home_checked">Last checked: %1$s</string>
<string name="home_incoming">Incoming · %1$s</string>
<string name="home_outgoing">Outgoing · %1$s</string>
<string name="home_no_activity">No commits in this direction.</string>
<plurals name="home_bounded">
<item quantity="one">Showing the latest %1$d commit.</item>
<item quantity="other">Showing the latest %1$d commits.</item>
</plurals>
<string name="home_commit_title">Commit password-store changes</string>
<string name="home_commit_message">Commit message</string>
<string name="home_commit_submit">Create commit</string>
<string name="app_name">IronStorage</string>
<string name="scaffold_title">No Password Store</string>
<string name="scaffold_detail">Your password store has not been configured.</string>
@@ -4,6 +4,9 @@ import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import de.rfc1437.ironstorage.bridge.generated.MobileHomeErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileHomeFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileHomeFreshness
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateDirectories
@@ -21,6 +24,80 @@ import org.junit.Assume.assumeTrue
import org.junit.Test
class AndroidOnboardingTest {
@Test fun homeUsesThePrivateRuntimeAndSharesDirtyEditorReservations() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val id = "home-${UUID.randomUUID()}"
val directories = MobilePrivateDirectories(
File(context.filesDir, id).apply { mkdirs() }.canonicalPath,
File(context.noBackupFilesDir, id).apply { mkdirs() }.canonicalPath,
File(context.cacheDir, id).apply { mkdirs() }.canonicalPath
)
val backend = MemoryBackend()
val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong =
android.os.SystemClock.elapsedRealtime().toULong()
}
NativeRuntime(context).privateStorage(directories).use { runtime ->
runtime.androidOnboarding(backend).use { setup ->
val key = requireNotNull(
setup.generate("Home fixture", "public-test-passphrase").selectedKey
)
setup.setupLocal(key, "public-test-passphrase")
}
runtime.authentication(backend, clock).use { authentication ->
val draft = authentication.beginCreateEntry("", "draft", "public-test-passphrase")
runtime.androidHomeOperation(backend, clock).use { home ->
assertFalse(home.cached().remoteConfigured)
assertFalse(home.activityKnown())
for (action in listOf({ home.pull() }, { home.push() })) {
val error =
assertThrows(MobileHomeFfiException.Failed::class.java) { action() }
assertEquals(MobileHomeErrorKind.DIRTY_LOCAL_CHANGES, error.kind)
}
assertTrue(authentication.entryEditor(draft.id).dirty)
}
authentication.saveEntryEditor(
draft.id,
listOf(
MobileEntryEditorInput(draft.page.fields[0].id, null, "fixture-password")
)
)
// Seed an uncommitted encrypted fixture; application code never reads the store.
val vault = File(runtime.layout().vault)
File(vault, "commit-fixture.gpg").writeBytes(File(vault, "draft.gpg").readBytes())
runtime.androidHomeOperation(backend, clock).use { home ->
assertTrue(home.cached().summaries.any { it.id.startsWith("local:") })
assertEquals(
"Changes Committed",
home.commit("Encrypted Home fixture").notice?.title
)
}
authentication.manualLock()
runtime.androidHomeOperation(backend, clock).use { home ->
assertEquals(MobileHomeFreshness.CURRENT, home.refreshGesture().freshness)
assertTrue(home.identity().name.isNotEmpty())
val error = assertThrows(MobileHomeFfiException.Failed::class.java) {
home.commit("No encrypted changes")
}
assertEquals(MobileHomeErrorKind.NO_CHANGES, error.kind)
for (action in listOf({ home.fetch() }, { home.pull() }, { home.push() })) {
val missing =
assertThrows(MobileHomeFfiException.Failed::class.java) { action() }
assertEquals(MobileHomeErrorKind.CONFIGURATION, missing.kind)
}
assertFalse(authentication.state().unlocked)
}
runtime.androidHomeOperation(backend, clock).use { home ->
home.cancel()
val error = assertThrows(MobileHomeFfiException.Failed::class.java) {
home.commit("Cancelled commit")
}
assertEquals(MobileHomeErrorKind.INTERRUPTED, error.kind)
}
}
}
}
@Test fun privateNavigationTraversesWithoutAuthenticationAndRejectsReplacedEntries() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val id = "navigation-${UUID.randomUUID()}"
@@ -193,6 +270,17 @@ class AndroidOnboardingTest {
assertFalse(config.contains("public-test-token"))
assertFalse(config.contains("public-replacement-token"))
}
val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong =
android.os.SystemClock.elapsedRealtime().toULong()
}
runtime.androidHomeOperation(backend, clock).use { home ->
assertTrue(home.cached().remoteConfigured)
assertEquals(MobileHomeFreshness.CURRENT, home.fetch().freshness)
// The local main branch is absent from this public repository's master branch.
assertFalse(home.activityKnown())
assertEquals("Tracked Branch Is Unavailable", home.cached().notice?.title)
}
}
}