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

@@ -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>