android: connect a server from the phone (M12 step 6)
The plumbing has been bound since step 4 — probe, link by password or token, unlink, sync — with nothing on top of it. Until this commit the phone was a good standalone notes app that could not be the SAME notes as the desktop, which is the point of the project. Structurally a port of the desktop's SyncView.vue: same probe-then-link order, same copy wherever the copy was already right. The two surfaces pair with the same servers, and a difference in wording here would read as a difference in behaviour. BEING UNLINKED IS NOT A PROBLEM, and the screen is written around that. It leads with "Working offline on this device" and says what connecting would ADD. A local-first app that frames its resting state as unfinished setup is lying about what it is. The drawer badge follows the same rule: it says nothing at all when unlinked, rather than "Off". Probe before credentials. A typo that reaches a stranger's server should cost a round trip, not a password — so the address is checked first, what answered is shown (name, version, compatibility), and only then does a sign-in form appear. An incompatible server never gets one; the core would refuse the link anyway, and collecting a password to throw away is worse than not asking. CLEARTEXT IS NOW PERMITTED, deliberately and not silently. Android blocks plain http from API 28, and the core explicitly supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a case it has a test for. The platform default would make this app unusable for exactly the people it is built for, with a transport error they could do nothing about. A network-security-config would be tighter in principle but matches domains and IP literals, not CIDR ranges, so it cannot express "my own network". The other half of the trade is a warning that appears the moment a probed address starts with http:// and BEFORE any credential field: anyone on the same network can read your password and your notes. Credentials never enter the view model. The address, email and device name are `rememberSaveable` so a rotation doesn't cost a retype; the password and the token are plain `remember` on purpose — rememberSaveable persists into the instance-state bundle, and a secret has no business being written there to save four seconds of typing. They reach the core as a `Credentials` sealed type and die with the composable. That sealed type also fixed a bug detekt surfaced by complaining about a six-parameter function: `link_with_token` takes NO device name (the token was already minted against a named device in the web app), so the flat argument list meant the form collected one in token mode and silently dropped it. The field now exists only on the password path. Threading, which differs by call and is easy to get wrong in one direction: probe / linkWithPassword / linkWithToken / unlink / syncNow are Rust async through uniffi, so Kotlin sees suspend functions already driven by tokio and awaits them directly — wrapping them in Dispatchers.IO would park a thread to wait on something that never blocks one. syncStatus and hasPending are ordinary blocking FFI into SQLite and do need it. A sync that changed anything tells the board to reload, because a pull can have rewritten every note it is holding. Wired explicitly at the one place that owns both view models rather than through a shared event bus. A no-op sync deliberately does not, so the board never flashes its loading state for nothing. Sync results are kept RAW in state and turned into sentences in the UI, where stringResource is in scope — the same split Time.kt draws for timestamps. The summary counts what MOVED; batches, pages, noop and cursor are all real numbers and none of them answer "are my notes in step". Rejections are surfaced rather than swallowed: only a person can resolve them. So is a revoke that didn't land — someone disconnecting to retire a phone has to be told a live credential is still out there, and has to still find it when they come back to check, so it is a persistent notice and not a toast. Also here: `Panel`/`Notice` extracted as shared tinted chrome, drawn from the same note palette the cards use rather than Material's errorContainer, so a warning is the same yellow a note can be. `PlainTextField` gained a visual transformation for the password field. `formatReminder` became `formatInstant` now that "last synced" reads it too. Verified locally per ci-requirements.md: ktlint and detekt clean in ci-rust-android:1.97, uniffi bindings generated from a host build and read to confirm ULong on the summary counters, `Compatibility.Ok`/`RevokeOutcome. Unsupported` being objects, and all five sync calls being suspend. Every R.string/R.plurals reference cross-checked for existence, kind and format arity. A symbol-resolution pass over the whole package caught a composable a bad edit had deleted — ktlint and detekt both parse without resolving, so neither could see it. Not done: no automatic sync. The desktop is manual-only too, so this is parity rather than a gap, but pull-to-refresh on the board is the obvious phone-native follow-up. Worth an operator decision, not changed here: allowBackup is still true, so Android's cloud backup now includes a device token as well as the notes. Good for restoring to a new phone, and a wider blast radius than before this commit. Scribe #2777 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,25 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
usesCleartextTraffic, deliberately.
|
||||||
|
|
||||||
|
Android blocks plain HTTP by default from API 28, and the core explicitly
|
||||||
|
supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a
|
||||||
|
case it has a test for. Leaving the platform default would make this app
|
||||||
|
unusable for exactly the people it is built for, with a transport error
|
||||||
|
they could do nothing about.
|
||||||
|
|
||||||
|
Scoped by the fact that the app talks to ONE host: the server the user
|
||||||
|
typed in. There is no ad SDK, no analytics, nothing else making requests.
|
||||||
|
A network-security-config would be tighter in principle, but it matches on
|
||||||
|
domains and IP literals rather than CIDR ranges, so it cannot express
|
||||||
|
"any address on my own network" — the case that actually matters here.
|
||||||
|
|
||||||
|
The trade is not made silently: the sync screen shows an unmissable
|
||||||
|
warning when the probed address is http://, BEFORE any credential field
|
||||||
|
appears. See SyncScreen.kt.
|
||||||
|
-->
|
||||||
<application
|
<application
|
||||||
android:name=".ThoughtSyncApplication"
|
android:name=".ThoughtSyncApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -15,7 +34,8 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ThoughtSync">
|
android:theme="@style/Theme.ThoughtSync"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.fabledsword.thoughtsync
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -9,6 +10,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||||
@@ -16,6 +18,8 @@ import com.fabledsword.thoughtsync.ui.BoardViewModel
|
|||||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||||
|
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||||
|
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -41,56 +45,111 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which screen is up. Exactly one at a time. */
|
||||||
|
private enum class Screen { BOARD, EDITOR, SYNC }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole app, once the store is open.
|
* The whole app, once the store is open.
|
||||||
*
|
*
|
||||||
* Board or editor, never both: the editor is a full screen, so composing the board
|
* One screen composed at a time, never stacked: the editor and the sync screen
|
||||||
* behind it would keep a two-column grid measuring and recomposing under something
|
* both cover the display completely, so keeping the board's two-column grid
|
||||||
* that entirely covers it.
|
* measuring and recomposing underneath one would be pure waste.
|
||||||
*
|
*
|
||||||
* No navigation library. There are exactly two destinations and the back gesture
|
* Still no navigation library. Three destinations, each entered from exactly one
|
||||||
* is handled by the editor itself — a nav graph here would be ceremony around a
|
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||||
* single nullable, and the editor's state already lives in the view model where a
|
* state that actually matters (which note is open, whether this device is linked)
|
||||||
* process death can restore it.
|
* already lives in view models.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun App(core: ThoughtSync) {
|
private fun App(core: ThoughtSync) {
|
||||||
val model: BoardViewModel = viewModel(factory = BoardViewModel.factory(core))
|
val board: BoardViewModel = viewModel(factory = BoardViewModel.factory(core))
|
||||||
// Sheet visibility is view STATE, not view-model state: it is about what is on
|
// A pull can rewrite every note the board is holding, so a sync that changed
|
||||||
// screen, and nothing in the store cares.
|
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||||
var composing by remember { mutableStateOf(false) }
|
val sync: SyncViewModel =
|
||||||
|
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||||
|
|
||||||
val editing = model.state.editing
|
// Sheet and screen visibility are view STATE, not view-model state: they are
|
||||||
if (editing != null) {
|
// about what is on the display, and nothing in the store cares.
|
||||||
NoteEditorScreen(
|
var composing by remember { mutableStateOf(false) }
|
||||||
note = editing,
|
var showingSync by remember { mutableStateOf(false) }
|
||||||
labels = model.state.labels,
|
|
||||||
saving = model.state.saving,
|
val editing = board.state.editing
|
||||||
error = model.state.error,
|
val screen =
|
||||||
// The one seam between the editor and the store. Exhaustive at the
|
when {
|
||||||
// other end, so a new action cannot be added without being handled.
|
showingSync -> Screen.SYNC
|
||||||
onAction = { model.onEditorAction(editing, it) },
|
editing != null -> Screen.EDITOR
|
||||||
)
|
else -> Screen.BOARD
|
||||||
return
|
}
|
||||||
|
|
||||||
|
when (screen) {
|
||||||
|
Screen.SYNC ->
|
||||||
|
SyncScreen(
|
||||||
|
state = sync.state,
|
||||||
|
onClose = { showingSync = false },
|
||||||
|
onProbe = sync::probe,
|
||||||
|
onClearProbe = sync::clearProbe,
|
||||||
|
onLink = sync::link,
|
||||||
|
onSyncNow = sync::syncNow,
|
||||||
|
onUnlink = sync::unlink,
|
||||||
|
onDismissRevokeNotice = sync::dismissRevokeNotice,
|
||||||
|
)
|
||||||
|
|
||||||
|
Screen.EDITOR ->
|
||||||
|
NoteEditorScreen(
|
||||||
|
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||||
|
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||||
|
labels = board.state.labels,
|
||||||
|
saving = board.state.saving,
|
||||||
|
error = board.state.error,
|
||||||
|
// The one seam between the editor and the store. Exhaustive at the
|
||||||
|
// other end, so a new action cannot be added without being handled.
|
||||||
|
onAction = { board.onEditorAction(editing, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Screen.BOARD -> {
|
||||||
|
BoardScreen(
|
||||||
|
state = board.state,
|
||||||
|
onOpen = board::open,
|
||||||
|
onOpenNote = board::openNote,
|
||||||
|
syncSummary = syncSummary(sync),
|
||||||
|
onOpenSync = { showingSync = true },
|
||||||
|
onSearch = board::search,
|
||||||
|
onCompose = { composing = true },
|
||||||
|
onDismissError = board::dismissError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (composing) {
|
||||||
|
ComposeSheet(
|
||||||
|
saving = board.state.saving,
|
||||||
|
onDismiss = { composing = false },
|
||||||
|
onSave = { kind, title, content ->
|
||||||
|
board.create(kind, title, content)
|
||||||
|
composing = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BoardScreen(
|
// The sync screen has no back handler of its own, so one lives here. The
|
||||||
state = model.state,
|
// editor keeps its own, because it has to save the open note before leaving.
|
||||||
onOpen = model::open,
|
BackHandler(enabled = showingSync) { showingSync = false }
|
||||||
onOpenNote = model::openNote,
|
}
|
||||||
onSearch = model::search,
|
|
||||||
onCompose = { composing = true },
|
|
||||||
onDismissError = model::dismissError,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (composing) {
|
/**
|
||||||
ComposeSheet(
|
* One line of sync state for the drawer, or null when there is nothing to say.
|
||||||
saving = model.state.saving,
|
*
|
||||||
onDismiss = { composing = false },
|
* Deliberately silent while the status is still loading and when the device is
|
||||||
onSave = { kind, title, content ->
|
* simply unlinked-and-idle — an "Off" badge on a local-first app would frame its
|
||||||
model.create(kind, title, content)
|
* normal resting state as something switched off.
|
||||||
composing = false
|
*/
|
||||||
},
|
@Composable
|
||||||
)
|
private fun syncSummary(sync: SyncViewModel): String? {
|
||||||
|
val state = sync.state
|
||||||
|
return when {
|
||||||
|
state.loading -> null
|
||||||
|
!state.linked -> null
|
||||||
|
state.pending -> stringResource(R.string.sync_badge_unsent)
|
||||||
|
else -> stringResource(R.string.sync_badge_on)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ fun BoardScreen(
|
|||||||
state: BoardState,
|
state: BoardState,
|
||||||
onOpen: (Destination) -> Unit,
|
onOpen: (Destination) -> Unit,
|
||||||
onOpenNote: (Note) -> Unit,
|
onOpenNote: (Note) -> Unit,
|
||||||
|
/**
|
||||||
|
* One line of sync state for the drawer, or null when there is nothing worth
|
||||||
|
* saying. Passed in rather than read from [BoardState]: sync has its own view
|
||||||
|
* model, and giving the board a copy of it would be two sources of truth for
|
||||||
|
* whether this device is linked.
|
||||||
|
*/
|
||||||
|
syncSummary: String?,
|
||||||
|
onOpenSync: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
@@ -69,10 +77,15 @@ fun BoardScreen(
|
|||||||
NavigationDrawer(
|
NavigationDrawer(
|
||||||
current = state.destination,
|
current = state.destination,
|
||||||
labels = state.labels,
|
labels = state.labels,
|
||||||
|
syncSummary = syncSummary,
|
||||||
onOpen = {
|
onOpen = {
|
||||||
onOpen(it)
|
onOpen(it)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onOpenSync = {
|
||||||
|
onOpenSync()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -172,7 +185,9 @@ private fun SearchBar(
|
|||||||
private fun NavigationDrawer(
|
private fun NavigationDrawer(
|
||||||
current: Destination,
|
current: Destination,
|
||||||
labels: List<Label>,
|
labels: List<Label>,
|
||||||
|
syncSummary: String?,
|
||||||
onOpen: (Destination) -> Unit,
|
onOpen: (Destination) -> Unit,
|
||||||
|
onOpenSync: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
@@ -203,6 +218,19 @@ private fun NavigationDrawer(
|
|||||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||||
DrawerRow(destination, current, onOpen)
|
DrawerRow(destination, current, onOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync sits below the divider with the destinations rather than behind
|
||||||
|
// a settings gear: it is not a preference, it is where you go to find
|
||||||
|
// out whether this phone and your desktop are actually in step. The
|
||||||
|
// subtitle answers that without opening it.
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.sync_title)) },
|
||||||
|
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
|
||||||
|
selected = false,
|
||||||
|
onClick = onOpenSync,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ fun ReminderSheet(
|
|||||||
|
|
||||||
reminderPresets().forEach { (labelRes, at) ->
|
reminderPresets().forEach { (labelRes, at) ->
|
||||||
Text(
|
Text(
|
||||||
text = "${stringResource(labelRes)} · ${formatReminder(rfc3339(at))}",
|
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.thoughtsync.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bordered block, tinted from the same table the notes use.
|
||||||
|
*
|
||||||
|
* Reusing the note palette rather than Material's `errorContainer` keeps the whole
|
||||||
|
* app one visual language: a warning here is the same yellow a note can be, which
|
||||||
|
* is also what the web app does with its Tailwind amber.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Panel(
|
||||||
|
tone: Tone = Tone.NEUTRAL,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint(tone.tintKey())
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(PANEL_RADIUS))
|
||||||
|
.background(tint.background(dark))
|
||||||
|
.border(1.dp, tint.border(dark), RoundedCornerShape(PANEL_RADIUS))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Notice(
|
||||||
|
tone: Tone,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Panel(tone = tone) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
onDismiss?.let {
|
||||||
|
TextButton(onClick = it) { Text(stringResource(R.string.error_dismiss)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three tones a panel or notice can take, mapped onto the note palette. */
|
||||||
|
enum class Tone { NEUTRAL, WARN, ERROR }
|
||||||
|
|
||||||
|
private fun Tone.tintKey(): String =
|
||||||
|
when (this) {
|
||||||
|
Tone.NEUTRAL -> "default"
|
||||||
|
Tone.WARN -> "yellow"
|
||||||
|
Tone.ERROR -> "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PANEL_RADIUS = 12.dp
|
||||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A text field with no box around it.
|
* A text field with no box around it.
|
||||||
@@ -43,6 +44,7 @@ fun PlainTextField(
|
|||||||
textStyle: TextStyle = LocalTextStyle.current,
|
textStyle: TextStyle = LocalTextStyle.current,
|
||||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
value = value,
|
value = value,
|
||||||
@@ -55,6 +57,7 @@ fun PlainTextField(
|
|||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
keyboardOptions = keyboardOptions,
|
keyboardOptions = keyboardOptions,
|
||||||
keyboardActions = keyboardActions,
|
keyboardActions = keyboardActions,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
colors =
|
colors =
|
||||||
TextFieldDefaults.colors(
|
TextFieldDefaults.colors(
|
||||||
// Full-strength, not Material's 38%-alpha disabled treatment: a
|
// Full-strength, not Material's 38%-alpha disabled treatment: a
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.thoughtsync.R
|
||||||
|
import com.fabledsword.thoughtsync.core.Compatibility
|
||||||
|
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
||||||
|
|
||||||
|
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
|
||||||
|
//
|
||||||
|
// Split from SyncScreen.kt because it is a different job. That file renders the
|
||||||
|
// state of an existing connection; this one is the several-step negotiation that
|
||||||
|
// creates one, and it is the half that has to be careful — it is where a password
|
||||||
|
// gets typed.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnlinkedPanel(
|
||||||
|
state: SyncState,
|
||||||
|
onProbe: (String) -> Unit,
|
||||||
|
onClearProbe: () -> Unit,
|
||||||
|
onLink: (String, Credentials) -> Unit,
|
||||||
|
onDismissRevokeNotice: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Saveable for the things it would be annoying to retype after a rotation —
|
||||||
|
// and deliberately NOT for the password or the token. `rememberSaveable`
|
||||||
|
// persists into the instance-state bundle, and a secret has no business being
|
||||||
|
// written there to save someone four seconds of typing.
|
||||||
|
var url by rememberSaveable { mutableStateOf("") }
|
||||||
|
var mode by rememberSaveable { mutableStateOf(LinkMode.PASSWORD) }
|
||||||
|
var email by rememberSaveable { mutableStateOf("") }
|
||||||
|
var deviceName by rememberSaveable { mutableStateOf(defaultDeviceName()) }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var token by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
state.lastRevoke?.let { RevokeNotice(revoke = it, onDismiss = onDismissRevokeNotice) }
|
||||||
|
|
||||||
|
Panel {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_offline_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_offline_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressSection(
|
||||||
|
url = url,
|
||||||
|
onUrlChange = {
|
||||||
|
url = it
|
||||||
|
// A probe describes ONE address. The moment it is edited the answer on
|
||||||
|
// screen is about a server the user is no longer asking about.
|
||||||
|
if (state.probe != null || state.probeError != null) onClearProbe()
|
||||||
|
},
|
||||||
|
busy = state.busy,
|
||||||
|
onProbe = { onProbe(url) },
|
||||||
|
)
|
||||||
|
|
||||||
|
ProbeSection(state)
|
||||||
|
|
||||||
|
if (state.probeUsable) {
|
||||||
|
SignInFields(
|
||||||
|
mode = mode,
|
||||||
|
onMode = { mode = it },
|
||||||
|
email = email,
|
||||||
|
onEmail = { email = it },
|
||||||
|
password = password,
|
||||||
|
onPassword = { password = it },
|
||||||
|
token = token,
|
||||||
|
onToken = { token = it },
|
||||||
|
deviceName = deviceName,
|
||||||
|
onDeviceName = { deviceName = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
state.linkError?.let {
|
||||||
|
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_link_failed), body = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.linking || state.syncing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
val credentials =
|
||||||
|
when (mode) {
|
||||||
|
LinkMode.PASSWORD ->
|
||||||
|
Credentials
|
||||||
|
.Password(email, password, deviceName)
|
||||||
|
.takeIf { email.isNotBlank() && password.isNotEmpty() }
|
||||||
|
LinkMode.TOKEN -> Credentials.Token(token).takeIf { token.isNotBlank() }
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { credentials?.let { onLink(url, it) } },
|
||||||
|
enabled = credentials != null && !state.busy,
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sync_connect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this device proves who it is.
|
||||||
|
*
|
||||||
|
* Two modes, because two situations: an email and password is what most people
|
||||||
|
* have, and a pasted device token is for anyone who would rather not type a
|
||||||
|
* password into an app — or whose account is behind SSO and has no password to
|
||||||
|
* type. Both are verified before anything is stored, so a slip fails here rather
|
||||||
|
* than at the next sync.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SignInFields(
|
||||||
|
mode: LinkMode,
|
||||||
|
onMode: (LinkMode) -> Unit,
|
||||||
|
email: String,
|
||||||
|
onEmail: (String) -> Unit,
|
||||||
|
password: String,
|
||||||
|
onPassword: (String) -> Unit,
|
||||||
|
token: String,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
deviceName: String,
|
||||||
|
onDeviceName: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_signin),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == LinkMode.PASSWORD,
|
||||||
|
onClick = { onMode(LinkMode.PASSWORD) },
|
||||||
|
label = { Text(stringResource(R.string.sync_mode_password)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == LinkMode.TOKEN,
|
||||||
|
onClick = { onMode(LinkMode.TOKEN) },
|
||||||
|
label = { Text(stringResource(R.string.sync_mode_token)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == LinkMode.PASSWORD) {
|
||||||
|
PlainTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = onEmail,
|
||||||
|
hint = R.string.sync_email,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
PlainTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = onPassword,
|
||||||
|
hint = R.string.sync_password,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
)
|
||||||
|
// Only on this path: a device token was already minted against a named
|
||||||
|
// device in the web app, so `link_with_token` takes no name and offering
|
||||||
|
// the field there would collect something with nowhere to go.
|
||||||
|
PlainTextField(
|
||||||
|
value = deviceName,
|
||||||
|
onValueChange = onDeviceName,
|
||||||
|
hint = R.string.sync_device_name,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_device_name_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PlainTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = onToken,
|
||||||
|
hint = R.string.sync_token,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_token_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The address field and its Check button — a probe, never a link. */
|
||||||
|
@Composable
|
||||||
|
private fun AddressSection(
|
||||||
|
url: String,
|
||||||
|
onUrlChange: (String) -> Unit,
|
||||||
|
busy: Boolean,
|
||||||
|
onProbe: () -> Unit,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_address_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PlainTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = onUrlChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
hint = R.string.sync_address_hint,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
// No autocapitalise, and a URI keyboard so the IME stops
|
||||||
|
// autocorrecting. A phone "helpfully" capitalising a hostname
|
||||||
|
// is the difference between connecting and a baffling failure.
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { onProbe() }),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onProbe, enabled = url.isNotBlank() && !busy) {
|
||||||
|
Text(stringResource(R.string.sync_check))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_address_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the probe produced: progress, failure, what answered, and the risk. */
|
||||||
|
@Composable
|
||||||
|
private fun ProbeSection(state: SyncState) {
|
||||||
|
if (state.probing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
state.probeError?.let {
|
||||||
|
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_probe_failed), body = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.probe?.let { probe ->
|
||||||
|
ProbeCard(probe.siteName, probe.version, probe.compatibility)
|
||||||
|
|
||||||
|
// Cleartext is permitted app-wide so a self-hosted server on a LAN works at
|
||||||
|
// all (the core explicitly supports `http://192.168.1.10:8000`). Permitting
|
||||||
|
// it silently would be the wrong half of that trade — this warning is what
|
||||||
|
// turns a platform default into an informed choice, and it appears BEFORE
|
||||||
|
// the credential fields rather than after.
|
||||||
|
if (probe.baseUrl.startsWith("http://")) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_insecure_title),
|
||||||
|
body = stringResource(R.string.sync_insecure_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What answered, shown BEFORE any credential is offered to it. */
|
||||||
|
@Composable
|
||||||
|
private fun ProbeCard(
|
||||||
|
siteName: String?,
|
||||||
|
version: String?,
|
||||||
|
compatibility: Compatibility,
|
||||||
|
) {
|
||||||
|
val incompatible = compatibility is Compatibility.Incompatible
|
||||||
|
Panel(tone = if (incompatible) Tone.ERROR else Tone.NEUTRAL) {
|
||||||
|
Text(
|
||||||
|
text = siteName ?: stringResource(R.string.sync_server_generic),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
version?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_server_version, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = describeCompatibility(compatibility),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color =
|
||||||
|
if (incompatible) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when unlinking could not retire the token server-side.
|
||||||
|
*
|
||||||
|
* Not a transient message. Someone who disconnected in order to retire a phone
|
||||||
|
* needs to know a live credential is still out there, and needs it to still be
|
||||||
|
* there when they come back to check.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RevokeNotice(
|
||||||
|
revoke: RevokeOutcome,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Revoked and Skipped are the fine cases and say nothing — a notice for "it
|
||||||
|
// worked" is noise on a screen someone is leaving.
|
||||||
|
val body =
|
||||||
|
when (revoke) {
|
||||||
|
is RevokeOutcome.Unsupported -> stringResource(R.string.sync_revoke_unsupported)
|
||||||
|
is RevokeOutcome.Failed -> stringResource(R.string.sync_revoke_failed, revoke.reason)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_revoke_title),
|
||||||
|
body = body,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phone's own name, so the server's device list reads usefully by default. */
|
||||||
|
private fun defaultDeviceName(): String =
|
||||||
|
listOfNotNull(Build.MANUFACTURER?.replaceFirstChar(Char::titlecase), Build.MODEL)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(" ")
|
||||||
|
.ifBlank { "Android phone" }
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
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.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.thoughtsync.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in server pairing.
|
||||||
|
*
|
||||||
|
* The whole screen is written around one idea: **being unlinked is not a
|
||||||
|
* problem.** ThoughtSync is local-first and completely usable having never opened
|
||||||
|
* this screen, so the unlinked state leads with "Working offline on this device"
|
||||||
|
* and explains what connecting would ADD, rather than presenting an empty form as
|
||||||
|
* unfinished setup.
|
||||||
|
*
|
||||||
|
* Structurally a port of the desktop's `SyncView.vue` — same probe-then-link
|
||||||
|
* order, same copy where the copy was already right — because the two surfaces
|
||||||
|
* pair with the same servers and a difference in wording here would read as a
|
||||||
|
* difference in behaviour.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SyncScreen(
|
||||||
|
state: SyncState,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onProbe: (String) -> Unit,
|
||||||
|
onClearProbe: () -> Unit,
|
||||||
|
onLink: (String, Credentials) -> Unit,
|
||||||
|
onSyncNow: () -> Unit,
|
||||||
|
onUnlink: () -> Unit,
|
||||||
|
onDismissRevokeNotice: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.sync_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.editor_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> CircularProgressIndicator(modifier = Modifier.padding(32.dp))
|
||||||
|
state.linked ->
|
||||||
|
LinkedPanel(
|
||||||
|
state = state,
|
||||||
|
onSyncNow = onSyncNow,
|
||||||
|
onUnlink = onUnlink,
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
UnlinkedPanel(
|
||||||
|
state = state,
|
||||||
|
onProbe = onProbe,
|
||||||
|
onClearProbe = onClearProbe,
|
||||||
|
onLink = onLink,
|
||||||
|
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── linked ─────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LinkedPanel(
|
||||||
|
state: SyncState,
|
||||||
|
onSyncNow: () -> Unit,
|
||||||
|
onUnlink: () -> Unit,
|
||||||
|
) {
|
||||||
|
var confirmingUnlink by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Panel {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_connected_to),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.status?.serverUrl.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
state.linkedAs?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_linked_as, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.sync_last_synced,
|
||||||
|
state.status?.lastSyncAt?.let { formatInstant(it) }
|
||||||
|
?: stringResource(R.string.sync_never),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (state.pending) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_unsent),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.syncing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onSyncNow, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.sync_now))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { confirmingUnlink = true }, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.sync_disconnect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastOutcome?.let { outcome ->
|
||||||
|
if (state.syncError == null) {
|
||||||
|
Text(
|
||||||
|
text = syncSummary(outcome),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Rejections are the server refusing a SPECIFIC change. Surfaced, never
|
||||||
|
// swallowed, because only a person can resolve them.
|
||||||
|
if (outcome.push.rejected > 0uL) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_rejected_title),
|
||||||
|
body =
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.sync_rejected_body,
|
||||||
|
outcome.push.rejected.toInt(),
|
||||||
|
outcome.push.rejected.toInt(),
|
||||||
|
outcome.push.errors.joinToString("; "),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.degraded.isNotEmpty()) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_degraded_title),
|
||||||
|
body = stringResource(R.string.sync_degraded_body, state.degraded.joinToString(", ")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.syncError?.let {
|
||||||
|
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_failed_title), body = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_footer),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmingUnlink) {
|
||||||
|
// Confirmed because it is not obvious what disconnecting does to the notes.
|
||||||
|
// The copy answers that first — they stay — since the fear it raises is
|
||||||
|
// "will this delete something?", not "am I sure?".
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmingUnlink = false },
|
||||||
|
title = { Text(stringResource(R.string.sync_disconnect_title)) },
|
||||||
|
text = { Text(stringResource(R.string.sync_disconnect_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
confirmingUnlink = false
|
||||||
|
onUnlink()
|
||||||
|
}) { Text(stringResource(R.string.sync_disconnect)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmingUnlink = false }) {
|
||||||
|
Text(stringResource(R.string.editor_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.fabledsword.thoughtsync.R
|
||||||
|
import com.fabledsword.thoughtsync.core.Compatibility
|
||||||
|
import com.fabledsword.thoughtsync.core.SyncOutcome
|
||||||
|
|
||||||
|
// Turning sync results into sentences.
|
||||||
|
//
|
||||||
|
// Composables rather than plain functions, because every word here comes from
|
||||||
|
// strings.xml and `stringResource` needs a composition. The view model keeps the
|
||||||
|
// raw `SyncOutcome`, which is the same split `Time.kt` draws: the core decides
|
||||||
|
// what happened, the UI decides how a person reads it.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a sync did, in one line.
|
||||||
|
*
|
||||||
|
* Counts what MOVED rather than everything the protocol reports. `batches`,
|
||||||
|
* `pages`, `noop` and `cursor` are all real numbers and none of them answer the
|
||||||
|
* question the person is actually asking, which is whether their notes are in
|
||||||
|
* step. A cycle that moved nothing says so plainly instead of listing zeroes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun syncSummary(outcome: SyncOutcome): String {
|
||||||
|
val sent = (outcome.push.created + outcome.push.applied).toInt()
|
||||||
|
val received = (outcome.pull.notesApplied + outcome.pull.notesDeleted).toInt()
|
||||||
|
val blobs = outcome.pull.blobsDownloaded.toInt()
|
||||||
|
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
|
||||||
|
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
|
||||||
|
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs)
|
||||||
|
|
||||||
|
val line =
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
stringResource(R.string.sync_summary_uptodate)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.sync_summary, parts.joinToString(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments that didn't arrive retry on the next cycle, so this is a note
|
||||||
|
// rather than an error — but saying nothing would leave a missing image
|
||||||
|
// looking like data loss.
|
||||||
|
val failed = outcome.pull.blobsFailed.toInt()
|
||||||
|
return if (failed > 0) {
|
||||||
|
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a probed server's compatibility means for the person reading it.
|
||||||
|
*
|
||||||
|
* `Incompatible` carries the core's own reason and is shown verbatim: the core
|
||||||
|
* knows which protocol version is missing and phrases it for a human, and
|
||||||
|
* substituting a generic "not compatible" here would throw that away.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun describeCompatibility(compatibility: Compatibility): String =
|
||||||
|
when (compatibility) {
|
||||||
|
is Compatibility.Ok -> stringResource(R.string.sync_compat_ok)
|
||||||
|
is Compatibility.Degraded ->
|
||||||
|
stringResource(
|
||||||
|
R.string.sync_compat_degraded,
|
||||||
|
compatibility.unavailable.joinToString(", "),
|
||||||
|
)
|
||||||
|
is Compatibility.Incompatible -> compatibility.reason
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.thoughtsync.core.Compatibility
|
||||||
|
import com.fabledsword.thoughtsync.core.ProbeResult
|
||||||
|
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
||||||
|
import com.fabledsword.thoughtsync.core.SyncOutcome
|
||||||
|
import com.fabledsword.thoughtsync.core.SyncStatus
|
||||||
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** How the device proves who it is when pairing. */
|
||||||
|
enum class LinkMode { PASSWORD, TOKEN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What gets sent to pair this device, by mode.
|
||||||
|
*
|
||||||
|
* A sealed type rather than six loose strings, and not only for the parameter
|
||||||
|
* count: the two modes genuinely carry different things. `link_with_token` takes
|
||||||
|
* NO device name — the token was already minted against a named device in the web
|
||||||
|
* app — so a flat argument list meant collecting one in token mode and silently
|
||||||
|
* dropping it. Modelling it this way made that impossible to express.
|
||||||
|
*/
|
||||||
|
sealed interface Credentials {
|
||||||
|
data class Password(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val deviceName: String,
|
||||||
|
) : Credentials
|
||||||
|
|
||||||
|
data class Token(
|
||||||
|
val token: String,
|
||||||
|
) : Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the sync screen renders from.
|
||||||
|
*
|
||||||
|
* Deliberately holds NO credentials. Email, password, token, address and device
|
||||||
|
* name live in the screen's own state and are handed to [SyncViewModel.link] at
|
||||||
|
* submit time, so a secret never outlives the composable that collected it — and
|
||||||
|
* never lands in a view model that survives the screen being closed.
|
||||||
|
*
|
||||||
|
* Results are kept RAW ([lastOutcome], [lastRevoke]) rather than as prose. Turning
|
||||||
|
* a sync result into a sentence is localisation, which belongs where
|
||||||
|
* `stringResource` is in scope; the same split `Time.kt` draws for timestamps.
|
||||||
|
*/
|
||||||
|
data class SyncState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val status: SyncStatus? = null,
|
||||||
|
val pending: Boolean = false,
|
||||||
|
val probing: Boolean = false,
|
||||||
|
val probe: ProbeResult? = null,
|
||||||
|
val probeError: String? = null,
|
||||||
|
val linking: Boolean = false,
|
||||||
|
val linkError: String? = null,
|
||||||
|
/** The account this device paired as, for the duration of the session. */
|
||||||
|
val linkedAs: String? = null,
|
||||||
|
/** Features this server doesn't have. Not an error — everything else syncs. */
|
||||||
|
val degraded: List<String> = emptyList(),
|
||||||
|
val syncing: Boolean = false,
|
||||||
|
val syncError: String? = null,
|
||||||
|
val lastOutcome: SyncOutcome? = null,
|
||||||
|
/** Set only when unlinking left the token alive server-side. */
|
||||||
|
val lastRevoke: RevokeOutcome? = null,
|
||||||
|
) {
|
||||||
|
val linked: Boolean get() = status?.linked == true
|
||||||
|
|
||||||
|
/** Any in-flight network call, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean get() = probing || linking || syncing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the server is usable at all.
|
||||||
|
*
|
||||||
|
* An incompatible server is the one probe result that must not lead to a
|
||||||
|
* credential prompt — the core refuses the link anyway, and offering the form
|
||||||
|
* would collect a password only to throw it away.
|
||||||
|
*/
|
||||||
|
val probeUsable: Boolean
|
||||||
|
get() = probe != null && probe.compatibility !is Compatibility.Incompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in server pairing and sync.
|
||||||
|
*
|
||||||
|
* Being UNLINKED is the resting state, not an incomplete setup: the app is
|
||||||
|
* local-first and entirely usable having never opened this screen. Nothing here
|
||||||
|
* may frame it as a problem to be fixed.
|
||||||
|
*
|
||||||
|
* ## Threading
|
||||||
|
*
|
||||||
|
* The two shapes are genuinely different and are called differently. `probe`,
|
||||||
|
* `linkWithPassword`, `linkWithToken`, `unlink` and `syncNow` are Rust `async`
|
||||||
|
* exported through uniffi, so Kotlin sees `suspend` functions already driven by a
|
||||||
|
* tokio runtime — they are awaited directly, and wrapping them in
|
||||||
|
* [Dispatchers.IO] would park a thread to wait on something that never blocks one.
|
||||||
|
* `syncStatus` and `hasPending` are ordinary blocking FFI into SQLite and do need
|
||||||
|
* the IO dispatcher, exactly like the board's calls.
|
||||||
|
*
|
||||||
|
* ## Cancellation
|
||||||
|
*
|
||||||
|
* Leaving the screen cancels [viewModelScope], which drops the Rust future
|
||||||
|
* mid-sync. That is safe by construction rather than by luck: no async path in the
|
||||||
|
* core holds the store lock across an await, and `last_sync_at` is stamped only
|
||||||
|
* after both halves of a cycle succeed, so an interrupted sync resumes from the
|
||||||
|
* stored cursor next time (Scribe #2736).
|
||||||
|
*/
|
||||||
|
class SyncViewModel(
|
||||||
|
private val core: ThoughtSync,
|
||||||
|
/**
|
||||||
|
* Called after a sync that changed the store.
|
||||||
|
*
|
||||||
|
* The board is a separate view model holding its own snapshot of the notes,
|
||||||
|
* and a pull can have rewritten every one of them underneath it. Wiring the
|
||||||
|
* two together explicitly is less magic than a shared event bus and makes the
|
||||||
|
* dependency visible at the construction site.
|
||||||
|
*/
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(SyncState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the stored link. Cheap and local — no network. */
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val status = withContext(Dispatchers.IO) { core.syncStatus() }
|
||||||
|
val pending = withContext(Dispatchers.IO) { core.hasPending() }
|
||||||
|
state.copy(status = status, pending = pending, loading = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// A store that won't answer is a real fault, but the screen
|
||||||
|
// still has to render — showing the unlinked state is honest,
|
||||||
|
// since without a readable link there is effectively none.
|
||||||
|
state.copy(loading = false, status = null, syncError = e.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask a server who it is, committing to nothing.
|
||||||
|
*
|
||||||
|
* Separated from linking on purpose: it is what lets someone see what answered
|
||||||
|
* BEFORE handing over a password. A typo that reaches a stranger's server
|
||||||
|
* should cost a round trip, not a credential.
|
||||||
|
*/
|
||||||
|
fun probe(url: String) {
|
||||||
|
if (url.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(probing = true, probeError = null, probe = null, linkError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
state.copy(probe = core.probe(url), probing = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(probing = false, probeError = e.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard the probe, so editing the address doesn't leave a stale answer up. */
|
||||||
|
fun clearProbe() {
|
||||||
|
state = state.copy(probe = null, probeError = null, linkError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair with the probed server, then immediately sync.
|
||||||
|
*
|
||||||
|
* The sync is part of the action, not a separate step the user has to think
|
||||||
|
* of: connecting an account and then facing an empty board would read as the
|
||||||
|
* link having failed.
|
||||||
|
*
|
||||||
|
* `url` comes from the PROBE's normalised `base_url` where there is one, so
|
||||||
|
* the address that was inspected is the address that gets paired — not a
|
||||||
|
* re-parse of whatever is currently in the text field.
|
||||||
|
*/
|
||||||
|
fun link(
|
||||||
|
url: String,
|
||||||
|
credentials: Credentials,
|
||||||
|
) {
|
||||||
|
val target = state.probe?.baseUrl ?: url
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(linking = true, linkError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val identity =
|
||||||
|
when (credentials) {
|
||||||
|
is Credentials.Password ->
|
||||||
|
core.linkWithPassword(
|
||||||
|
target,
|
||||||
|
credentials.email.trim(),
|
||||||
|
credentials.password,
|
||||||
|
credentials.deviceName.trim(),
|
||||||
|
)
|
||||||
|
is Credentials.Token ->
|
||||||
|
core.linkWithToken(target, credentials.token.trim())
|
||||||
|
}
|
||||||
|
val compatibility = state.probe?.compatibility
|
||||||
|
state.copy(
|
||||||
|
linking = false,
|
||||||
|
linkedAs = identity.email,
|
||||||
|
degraded =
|
||||||
|
(compatibility as? Compatibility.Degraded)?.unavailable ?: emptyList(),
|
||||||
|
// The probe has done its job; leaving it up would keep the
|
||||||
|
// connect form on screen next to a live connection.
|
||||||
|
probe = null,
|
||||||
|
status = withContext(Dispatchers.IO) { core.syncStatus() },
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(linking = false, linkError = e.describe())
|
||||||
|
}
|
||||||
|
if (state.linked) syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncNow() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(syncing = true, syncError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val outcome = core.syncNow()
|
||||||
|
state.copy(
|
||||||
|
syncing = false,
|
||||||
|
lastOutcome = outcome,
|
||||||
|
status = outcome.status,
|
||||||
|
pending = withContext(Dispatchers.IO) { core.hasPending() },
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(syncing = false, syncError = e.describe())
|
||||||
|
}
|
||||||
|
// Only when something actually arrived: a no-op sync must not make the
|
||||||
|
// board flash its loading state for nothing.
|
||||||
|
if (state.lastOutcome?.changedTheStore() == true) onStoreChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop syncing, retiring this device's token on the server.
|
||||||
|
*
|
||||||
|
* The local half is unconditional in the core — someone unlinking because the
|
||||||
|
* phone is being sold must not be held to it by a server that is offline. The
|
||||||
|
* revoke outcome comes back so the screen can say plainly when the token is
|
||||||
|
* still live, which is the one thing about this flow worth interrupting for.
|
||||||
|
*/
|
||||||
|
fun unlink() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(syncing = true, syncError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val revoked = core.unlink()
|
||||||
|
state.copy(
|
||||||
|
syncing = false,
|
||||||
|
lastRevoke = revoked,
|
||||||
|
status = withContext(Dispatchers.IO) { core.syncStatus() },
|
||||||
|
// Everything below described the connection that just ended.
|
||||||
|
linkedAs = null,
|
||||||
|
degraded = emptyList(),
|
||||||
|
lastOutcome = null,
|
||||||
|
pending = false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(syncing = false, syncError = e.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRevokeNotice() {
|
||||||
|
state = state.copy(lastRevoke = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: ThoughtSync,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = SyncViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a sync actually moved anything, so the board is only reloaded when its
|
||||||
|
* contents can have changed.
|
||||||
|
*
|
||||||
|
* Attachments count: a note whose image finally downloaded renders differently
|
||||||
|
* even though the note row itself is untouched.
|
||||||
|
*/
|
||||||
|
private fun SyncOutcome.changedTheStore(): Boolean =
|
||||||
|
pull.notesApplied > 0uL ||
|
||||||
|
pull.notesDeleted > 0uL ||
|
||||||
|
pull.labelsApplied > 0uL ||
|
||||||
|
pull.labelsDeleted > 0uL ||
|
||||||
|
pull.blobsDownloaded > 0uL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message to show for a failure.
|
||||||
|
*
|
||||||
|
* The core writes these for people to read — "notes.example.com responded, but not
|
||||||
|
* with ThoughtSync's configuration" — so they are shown as-is rather than
|
||||||
|
* replaced with a generic string that would throw away the only useful part.
|
||||||
|
*/
|
||||||
|
private fun Exception.describe(): String = message ?: "Something went wrong."
|
||||||
@@ -41,11 +41,14 @@ fun localTime(raw: String): LocalDateTime? =
|
|||||||
/**
|
/**
|
||||||
* A stored instant in the device's own locale and zone.
|
* A stored instant in the device's own locale and zone.
|
||||||
*
|
*
|
||||||
|
* Used for reminders and for "last synced" — anywhere the core hands the UI an
|
||||||
|
* RFC3339 string and a person has to read it.
|
||||||
|
*
|
||||||
* A string we cannot parse is shown verbatim rather than swallowed: a visibly odd
|
* A string we cannot parse is shown verbatim rather than swallowed: a visibly odd
|
||||||
* reminder beats a silently missing one, and the raw value is what someone would
|
* reminder beats a silently missing one, and the raw value is what someone would
|
||||||
* need in order to report it.
|
* need in order to report it.
|
||||||
*/
|
*/
|
||||||
fun formatReminder(raw: String): String =
|
fun formatInstant(raw: String): String =
|
||||||
localTime(raw)
|
localTime(raw)
|
||||||
?.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
|
?.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
|
||||||
?: raw
|
?: raw
|
||||||
@@ -57,7 +60,7 @@ fun reminderLabel(
|
|||||||
): String =
|
): String =
|
||||||
buildString {
|
buildString {
|
||||||
append("⏰ ")
|
append("⏰ ")
|
||||||
append(formatReminder(raw))
|
append(formatInstant(raw))
|
||||||
if (!recurrence.isNullOrBlank()) {
|
if (!recurrence.isNullOrBlank()) {
|
||||||
append(" · ↻ ")
|
append(" · ↻ ")
|
||||||
append(recurrence)
|
append(recurrence)
|
||||||
|
|||||||
@@ -96,6 +96,86 @@
|
|||||||
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
|
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
|
||||||
<string name="store_unavailable_body">The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost.</string>
|
<string name="store_unavailable_body">The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost.</string>
|
||||||
|
|
||||||
|
<!-- Sync. Opt-in, and the copy has to carry that: being unlinked is the
|
||||||
|
normal resting state of a local-first app, not unfinished setup. -->
|
||||||
|
<string name="sync_title">Sync</string>
|
||||||
|
<string name="sync_badge_on">On</string>
|
||||||
|
<string name="sync_badge_unsent">Unsent</string>
|
||||||
|
|
||||||
|
<!-- Linked -->
|
||||||
|
<string name="sync_connected_to">Connected to</string>
|
||||||
|
<string name="sync_linked_as">as %1$s</string>
|
||||||
|
<string name="sync_last_synced">Last synced %1$s</string>
|
||||||
|
<string name="sync_never">never</string>
|
||||||
|
<string name="sync_unsent">This device has changes that haven\'t been sent yet.</string>
|
||||||
|
<string name="sync_now">Sync now</string>
|
||||||
|
<string name="sync_disconnect">Disconnect</string>
|
||||||
|
<string name="sync_disconnect_title">Stop syncing with this server?</string>
|
||||||
|
<string name="sync_disconnect_body">Your notes stay on this device, and the copy on the server is left alone. This device\'s access token is revoked, so it can\'t be used to reach the server again.</string>
|
||||||
|
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
|
||||||
|
<string name="sync_failed_title">Sync failed</string>
|
||||||
|
<string name="sync_rejected_title">The server wouldn\'t accept some changes</string>
|
||||||
|
<plurals name="sync_rejected_body">
|
||||||
|
<item quantity="one">%1$d change was rejected: %2$s</item>
|
||||||
|
<item quantity="other">%1$d changes were rejected: %2$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="sync_degraded_title">Some features aren\'t available here</string>
|
||||||
|
<string name="sync_degraded_body">This server doesn\'t support: %1$s. Everything else syncs normally.</string>
|
||||||
|
|
||||||
|
<!-- Unlinked -->
|
||||||
|
<string name="sync_offline_title">Working offline on this device</string>
|
||||||
|
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
|
||||||
|
<string name="sync_address_label">Server address</string>
|
||||||
|
<string name="sync_address_hint">notes.example.com</string>
|
||||||
|
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
|
||||||
|
<string name="sync_check">Check</string>
|
||||||
|
<string name="sync_probe_failed">Couldn\'t reach that server</string>
|
||||||
|
<string name="sync_link_failed">Couldn\'t connect</string>
|
||||||
|
<string name="sync_server_generic">ThoughtSync server</string>
|
||||||
|
<string name="sync_server_version">v%1$s</string>
|
||||||
|
<string name="sync_compat_ok">Fully compatible.</string>
|
||||||
|
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
|
||||||
|
|
||||||
|
<!-- Shown before any credential field, whenever the probed address is http://.
|
||||||
|
Android blocks cleartext by default and this app allows it so that a
|
||||||
|
self-hosted server on a LAN works at all; this is the other half of
|
||||||
|
that trade. -->
|
||||||
|
<string name="sync_insecure_title">This connection isn\'t encrypted</string>
|
||||||
|
<string name="sync_insecure_body">You\'re about to sign in over plain http. Anyone on the same network can read your password and your notes. Use https unless this is a server you control, on a network you trust.</string>
|
||||||
|
|
||||||
|
<string name="sync_signin">Sign in</string>
|
||||||
|
<string name="sync_mode_password">Email and password</string>
|
||||||
|
<string name="sync_mode_token">Device token</string>
|
||||||
|
<string name="sync_email">Email</string>
|
||||||
|
<string name="sync_password">Password</string>
|
||||||
|
<string name="sync_token">Paste a device token</string>
|
||||||
|
<string name="sync_token_help">Create one in the web app under Account → Linked devices.</string>
|
||||||
|
<string name="sync_device_name">Name for this device</string>
|
||||||
|
<string name="sync_device_name_help">Shown in your account\'s list of linked devices.</string>
|
||||||
|
<string name="sync_connect">Connect and sync</string>
|
||||||
|
|
||||||
|
<!-- An unlink whose server-side revoke didn\'t land leaves a live credential.
|
||||||
|
Never a transient message: someone disconnecting to retire a phone has to
|
||||||
|
still find this when they come back to check. -->
|
||||||
|
<string name="sync_revoke_title">This device\'s token is still valid on the server</string>
|
||||||
|
<string name="sync_revoke_unsupported">This server is older than in-app sign-out, so this device\'s token had to be left in place. Revoke it in the web app under Account → Linked devices.</string>
|
||||||
|
<string name="sync_revoke_failed">%1$s Until it\'s revoked, this device\'s token still works — you can revoke it in the web app under Account → Linked devices.</string>
|
||||||
|
|
||||||
|
<!-- What a sync did. Counts what MOVED; batches, pages and cursors are real
|
||||||
|
numbers that answer nobody\'s question. -->
|
||||||
|
<string name="sync_summary">Synced — %1$s.</string>
|
||||||
|
<string name="sync_summary_sent">sent %1$d</string>
|
||||||
|
<string name="sync_summary_received">received %1$d</string>
|
||||||
|
<string name="sync_summary_uptodate">Already up to date.</string>
|
||||||
|
<plurals name="sync_summary_attachments">
|
||||||
|
<item quantity="one">%d attachment</item>
|
||||||
|
<item quantity="other">%d attachments</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_summary_attachments_failed">
|
||||||
|
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
|
||||||
|
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Errors -->
|
<!-- Errors -->
|
||||||
<string name="error_dismiss">Dismiss</string>
|
<string name="error_dismiss">Dismiss</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user