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