Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3e29e4f6 | ||
|
|
c851b901df | ||
|
|
abe01da5f7 | ||
|
|
09b5f874b6 | ||
|
|
a85c53ba2c | ||
|
|
2141a0ac45 | ||
|
|
1aca294b95 | ||
|
|
7033995975 | ||
|
|
de72d27bd4 | ||
|
|
c99cbb3e14 | ||
|
|
6f21db85a1 | ||
|
|
924ddb20db | ||
|
|
95aa10c2c3 | ||
|
|
6d778f26a7 | ||
|
|
33e9278975 | ||
|
|
c46a4a7709 | ||
|
|
229076c82d | ||
|
|
ad21eac5bc | ||
|
|
bc22f8e249 | ||
|
|
982d24c83b | ||
|
|
bacedea8a3 | ||
|
|
b6152ec18b | ||
|
|
16f86bef93 | ||
|
|
867405fae2 | ||
|
|
81695fa0c8 | ||
|
|
0cf77336d4 | ||
|
|
010e9a2f85 | ||
|
|
43ebb6eceb | ||
|
|
e6da720e6b | ||
|
|
d77a79859c | ||
|
|
6589be2b0f | ||
|
|
cae9888eb9 | ||
|
|
d0a9c73bf9 | ||
|
|
f38864088b | ||
|
|
8f13dc2e2c | ||
|
|
785ebdba59 | ||
|
|
39170b715c | ||
|
|
5680f046e3 | ||
|
|
452c66c8ef | ||
|
|
64542ed6cb | ||
|
|
65d8f5f9c6 | ||
|
|
750d11d32e | ||
|
|
cf0ce382a0 | ||
|
|
64e016f32d | ||
|
|
eb3dc3d893 | ||
|
|
c8af808432 | ||
|
|
5eab2dd0b3 | ||
|
|
dee71dffb3 | ||
|
|
5d0de7a682 | ||
|
|
3d3df1beb0 | ||
|
|
f179928c57 | ||
|
|
20907abf6e | ||
|
|
e7937ea87e | ||
|
|
b3309e29f8 | ||
|
|
f90b9203a7 | ||
|
|
9f981ca47e | ||
|
|
e696b23417 | ||
|
|
0a7480cf9b | ||
|
|
c28f2bc00e | ||
|
|
40cb463be7 | ||
|
|
641999de58 | ||
|
|
c8c8ec4b4e | ||
|
|
67b9ea2938 | ||
|
|
18a58fb5da | ||
|
|
e8d6a4f423 | ||
|
|
1f140c7457 | ||
|
|
be0eb94225 | ||
|
|
f5837cd985 | ||
|
|
e7ee16c6cf | ||
|
|
d6646a64fb | ||
|
|
3a1496e5fa | ||
|
|
659237ccc6 | ||
|
|
c883fd2eb6 | ||
|
|
5c1ae574f6 | ||
|
|
2cfe049f9c | ||
|
|
edf52da97f | ||
|
|
c1464228df | ||
|
|
505904b1e5 | ||
|
|
13e48672c0 | ||
|
|
8b6dfab3a7 | ||
|
|
d8b0cd9b96 | ||
|
|
6f47af8d96 | ||
|
|
acff95f920 | ||
|
|
3ca3eba6d5 |
@@ -39,6 +39,16 @@ POSTGRES_PASSWORD=
|
||||
# 127.0.0.1 so only the proxy can talk to it.
|
||||
#THOUGHTSYNC_BIND=0.0.0.0
|
||||
|
||||
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
|
||||
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
||||
# reverse proxy terminating HTTPS) and belongs there because it is something you may
|
||||
# need to change while the server is running, alongside the sign-in limits.
|
||||
|
||||
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
||||
# accounts, device tokens issued) are logged at INFO and read with
|
||||
# `docker compose logs app`.
|
||||
#THOUGHTSYNC_LOG_LEVEL=INFO
|
||||
|
||||
# Database identity. Changing these AFTER the first start does not rename anything
|
||||
# that already exists — the volume keeps whatever the first run created.
|
||||
#POSTGRES_USER=thoughtsync
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
name: Android
|
||||
|
||||
# The native Kotlin/Compose client over the shared Rust core (M12).
|
||||
#
|
||||
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
||||
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
||||
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
|
||||
# uniffi.
|
||||
#
|
||||
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
||||
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
||||
# phone.
|
||||
#
|
||||
# The artifact is a SIGNED RELEASE APK when the keystore secret is present, and an
|
||||
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
|
||||
# signed with different keys cannot replace one another, and bridging that gap
|
||||
# means uninstalling first — which deletes the app's database and every local note
|
||||
# with it (Scribe issue 2803).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- "android/**"
|
||||
# The Rust the .so is built from. A core change reaches the phone exactly
|
||||
# as it reaches the desktop, so this lane has to rebuild on it.
|
||||
- "core/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".forgejo/workflows/android.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
||||
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
||||
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
||||
# gradle.properties is not enough on its own (Minstrel hit the same thing).
|
||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Kotlin + Rust (APK)
|
||||
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||
# proven-working label that can pull our container images.
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
|
||||
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
||||
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# For the dispatch at the end: this lane starts the server image build.
|
||||
actions: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Gradle and Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.kotlin
|
||||
target
|
||||
key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }}
|
||||
restore-keys: |
|
||||
android-
|
||||
|
||||
# Everything downstream keys off this: the variant to build, the Cargo
|
||||
# profile to build it with, and the version it carries. Decided once so no
|
||||
# two Gradle invocations in this run can disagree and force a second
|
||||
# four-minute cross-compile.
|
||||
- name: Signing key, variant and version
|
||||
id: build
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
version="$(sh ../desktop/packaging/build-version.sh)"
|
||||
echo "name=$version" >> $GITHUB_OUTPUT
|
||||
# versionCode must RISE for Android to accept an update, and the run
|
||||
# number is the same monotonic counter the desktop's version scheme
|
||||
# already uses — no state carried between runs, and immune to the
|
||||
# shallow checkout that makes a commit count useless here.
|
||||
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
||||
echo "variant=Release" >> $GITHUB_OUTPUT
|
||||
echo "label=release" >> $GITHUB_OUTPUT
|
||||
# DEBUG profile, in a release APK, deliberately — see the note above
|
||||
# the cargoNdk task. The release profile strips the symbols uniffi
|
||||
# reads its metadata out of, so `generateUniffiBindings` fails
|
||||
# outright (run 4077). Unpicking that is worth doing and is not worth
|
||||
# blocking signed builds on.
|
||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
|
||||
else
|
||||
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
||||
echo "variant=Debug" >> $GITHUB_OUTPUT
|
||||
echo "label=debug" >> $GITHUB_OUTPUT
|
||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||
echo "keystore=" >> $GITHUB_OUTPUT
|
||||
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
# Fails loudly here if the wrapper and the image's JDK disagree, rather
|
||||
# than thirty seconds into a compile with an opaque version message.
|
||||
- name: Gradle wrapper check
|
||||
run: ./gradlew --version
|
||||
|
||||
# Cross-compiles the core for four ABIs and generates the Kotlin bindings
|
||||
# from the built .so. Run as its own step so a Rust failure is legible as a
|
||||
# Rust failure instead of arriving inside a Gradle stack trace.
|
||||
- name: Build the native library and bindings
|
||||
run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
|
||||
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
|
||||
# (M12 step 3) precisely so this lane needs no second image, and going
|
||||
# through Gradle plugins would mean a second version of each tool resolved
|
||||
# at build time and kept in lockstep with the image's by hand.
|
||||
#
|
||||
# Scoped to src/main: the generated uniffi bindings live under build/ and
|
||||
# are not ours to style.
|
||||
- name: ktlint
|
||||
run: ktlint "app/src/main/**/*.kt"
|
||||
|
||||
- name: detekt
|
||||
run: detekt --build-upon-default-config --config config/detekt.yml --input app/src/main/java
|
||||
|
||||
- name: Unit tests
|
||||
# Host-JVM tests only. Anything touching the core needs an Android
|
||||
# runtime to load the .so, so those are instrumented tests and belong on
|
||||
# an emulator, not here — the Rust side is covered by the workspace
|
||||
# tests in the desktop lane.
|
||||
#
|
||||
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
||||
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
||||
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
||||
# type-check on the debug variant, which is the one an emulator build
|
||||
# would use.
|
||||
run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
|
||||
- name: Assemble the APK
|
||||
env:
|
||||
# Empty on the unsigned path, which build.gradle.kts reads as "no
|
||||
# signing config" rather than as a path to a missing file.
|
||||
ANDROID_KEYSTORE_FILE: ${{ steps.build.outputs.keystore }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
./gradlew assemble${{ steps.build.outputs.variant }} \
|
||||
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
||||
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
|
||||
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
|
||||
|
||||
# Prints the certificate the APK was actually signed with, so the operator
|
||||
# can compare it against the fingerprint recorded when the key was
|
||||
# generated. Signing with the WRONG key produces a perfectly valid APK that
|
||||
# simply refuses to install over the app already on the phone — a failure
|
||||
# that otherwise only shows up on the device, after the run is green.
|
||||
- name: Show the signing certificate
|
||||
if: steps.build.outputs.keystore != ''
|
||||
run: |
|
||||
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
|
||||
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
|
||||
|
||||
# Staged with a STABLE name plus the sidecar the server reads its version
|
||||
# out of — an APK keeps that in a binary manifest Python cannot parse, and
|
||||
# `aapt` is not on a Quart server. Computed here, where the real values are
|
||||
# already known.
|
||||
- name: Stage the client for distribution
|
||||
if: steps.build.outputs.keystore != ''
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
|
||||
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
|
||||
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
|
||||
cat > dist/thoughtsync-android.json <<JSON
|
||||
{
|
||||
"version_name": "${{ steps.build.outputs.name }}",
|
||||
"version_code": ${{ steps.build.outputs.code }},
|
||||
"size": $size,
|
||||
"sha256": "$sha"
|
||||
}
|
||||
JSON
|
||||
cat dist/thoughtsync-android.json
|
||||
|
||||
# The rolling dev channel, same fixed-tag release the desktop bundles use.
|
||||
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
|
||||
# target; a release asset has a permanent URL. Only ever a SIGNED build —
|
||||
# publishing an unsigned APK would offer people something they cannot
|
||||
# install over what they already have.
|
||||
- name: Publish to the dev channel
|
||||
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
|
||||
working-directory: .
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: dev
|
||||
RELEASE_PRERELEASE: "true"
|
||||
run: bash desktop/packaging/publish-release.sh
|
||||
|
||||
- name: Upload the APK
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
||||
# it reports success while Gitea serves artifacts back only through the
|
||||
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
||||
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
||||
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
# The APK's variant, NOT the Cargo profile — those are the same word
|
||||
# for different things and the profile is pinned to debug (#2810).
|
||||
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
||||
path: ${{ steps.build.outputs.apk }}
|
||||
if-no-files-found: error
|
||||
|
||||
# The server image bakes in whatever client the dev release holds, so it has
|
||||
# to be built AFTER this lane, not alongside it. `ci.yml` stands down on any
|
||||
# push that touches the Android app (its `gate` job) and waits to be called
|
||||
# from here — that is the other half of this.
|
||||
#
|
||||
# `always()`: a FAILED Android build must still let the server image through.
|
||||
# There is no new client in that case, so it bakes in the previous one, which
|
||||
# is exactly right — the alternative is a broken Android lane silently
|
||||
# blocking server delivery.
|
||||
#
|
||||
# Not `if: success()` and not skipped on tags either: every ref that builds an
|
||||
# image needs the call, or nothing builds one at all.
|
||||
- name: Build the server image now the client is published
|
||||
if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
working-directory: .
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Loud on failure rather than `|| true`: if this call stops working, the
|
||||
# symptom is server images silently never being built for Android pushes,
|
||||
# which is invisible until someone wonders why the app never updates.
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"${{ github.ref_name }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches"
|
||||
echo "Dispatched ci.yml on ${{ github.ref_name }}."
|
||||
+209
-3
@@ -27,6 +27,10 @@ on:
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# Dispatched by the Android lane once it has published a client, so the image
|
||||
# that bakes it in is built AFTER the APK exists rather than racing it. See the
|
||||
# `gate` job below for the other half.
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
|
||||
# own group implicitly and are never cancelled.
|
||||
@@ -42,6 +46,98 @@ env:
|
||||
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
|
||||
|
||||
jobs:
|
||||
# Should this push build an image now, or is the Android lane about to publish a
|
||||
# client that the image ought to contain?
|
||||
#
|
||||
# A push touching the Android app runs BOTH workflows at once. Building here
|
||||
# would bake in the PREVIOUS client and then, when the new one landed, there
|
||||
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
|
||||
# and rebuilding it with different content would make it neither.
|
||||
#
|
||||
# So on such a push this workflow stands down, and the Android lane dispatches it
|
||||
# when it is finished. Exactly one image per commit, containing the client from
|
||||
# that commit.
|
||||
#
|
||||
# The path list below MUST match android.yml's trigger. Two places holding one
|
||||
# decision is the recurring failure in this repo (issues 2181-2183); it is here
|
||||
# because a workflow cannot read another's filters, and it is a `git diff` rather
|
||||
# than a config so at least it is inspectable in the log.
|
||||
gate:
|
||||
name: Build now, or wait for Android?
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
outputs:
|
||||
build: ${{ steps.decide.outputs.build }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Full history: the diff below spans the whole PUSHED RANGE, not just the
|
||||
# tip. A push of three commits whose Android change sits in the first
|
||||
# would otherwise look Android-free, and the race this job exists to
|
||||
# prevent would happen anyway — silently, which is the worst version.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
run: |
|
||||
# A dispatched run IS the Android lane calling back. Always build.
|
||||
if [ "${{ github.event_name }}" != "push" ]; then
|
||||
echo "Dispatched by the Android lane — building."
|
||||
echo "build=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A tag. The Android lane does not run on tags, so nothing would ever
|
||||
# call back — standing down here would mean a release tag that never
|
||||
# produces an image at all.
|
||||
case "${{ github.ref }}" in
|
||||
refs/tags/*)
|
||||
echo "Tag build — the Android lane does not run on tags. Building."
|
||||
echo "build=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
||||
# compare, so build rather than stall.
|
||||
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
||||
echo "No parent commit to diff against — building."
|
||||
echo "build=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The whole push, not just its tip. `before` is what the ref pointed at
|
||||
# beforehand; it is absent or all-zeros for a brand-new branch, and may
|
||||
# be unreachable after a force-push — fall back to the tip commit then.
|
||||
before="${{ github.event.before }}"
|
||||
if [ -n "$before" ] \
|
||||
&& [ "$before" != "0000000000000000000000000000000000000000" ] \
|
||||
&& git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||
range="$before..HEAD"
|
||||
else
|
||||
range="HEAD^..HEAD"
|
||||
fi
|
||||
echo "Comparing $range"
|
||||
|
||||
changed="$(git diff --name-only $range)"
|
||||
echo "Changed in this push:"
|
||||
echo "$changed" | sed 's/^/ /'
|
||||
|
||||
if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
||||
echo ""
|
||||
echo "This push also changes the Android client. Standing down: the"
|
||||
echo "Android lane will publish a new APK and dispatch this workflow,"
|
||||
echo "so the image is built once, with the client from this commit."
|
||||
echo "build=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ""
|
||||
echo "No Android change — the newest published client is already the"
|
||||
echo "right one to bake in. Building."
|
||||
echo "build=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -88,15 +184,88 @@ jobs:
|
||||
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
||||
# DB-free by design. Anything needing a real Postgres is marked `integration`
|
||||
# and runs in the job below.
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||
|
||||
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
|
||||
# for the first time when the operator's container started — 26 revisions, none of
|
||||
# them ever executed by CI — and the schema the migrations build had never been
|
||||
# checked against the models that read it.
|
||||
#
|
||||
# Runs for visibility and does NOT gate the build, matching the `test` lane and
|
||||
# FabledScribe's equivalent job.
|
||||
#
|
||||
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
|
||||
# derives the service-container name from the truncated job display name, and the
|
||||
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
||||
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
||||
integration:
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
services:
|
||||
postgres:
|
||||
# Same image the production compose runs, so the schema is proven against the
|
||||
# Postgres it will actually meet.
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: thoughtsync
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: thoughtsync_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U thoughtsync"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Create virtual environment
|
||||
run: uv venv /opt/venv
|
||||
|
||||
# Same install as the unit lane — the two must agree on versions, or
|
||||
# "unit green, integration red" stops being a signal about the code.
|
||||
- name: Install package with dev deps
|
||||
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
|
||||
test -n "$PG"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
|
||||
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
||||
# no bash /dev/tcp — so use the Python that is always present here.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
try:
|
||||
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
else:
|
||||
sys.exit("postgres did not become reachable")
|
||||
PY
|
||||
# Real migrations build the schema, never metadata.create_all (rule 82) —
|
||||
# testing a schema no deployment has ever seen would prove nothing. This
|
||||
# step IS the migration test: a broken revision fails the job here.
|
||||
/opt/venv/bin/alembic upgrade head
|
||||
/opt/venv/bin/python -m pytest tests/ -v -m integration
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# Build gates on lint + typecheck. The `test` job runs in parallel for
|
||||
# visibility but does not block dev image builds (DB-backed integration
|
||||
# testing happens against the dev image manually, not on every push).
|
||||
needs: [typecheck, lint]
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [gate, typecheck, lint]
|
||||
if: needs.gate.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -136,6 +305,43 @@ jobs:
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||
# along with the new server.
|
||||
#
|
||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||
# image therefore carries the newest client rather than one pinned to that
|
||||
# version; the two negotiate a sync protocol version before linking, so
|
||||
# "newest" is safe in a way "matching" would not buy anything over.
|
||||
#
|
||||
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
||||
# token used inside a build lands in the context or a layer.
|
||||
#
|
||||
# NEVER fails the build. An image with no Android client advertises none and
|
||||
# hides the download — a supported state, and the only one available before
|
||||
# the first Android build has ever published.
|
||||
- name: Fetch the Android client to bake in
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p client
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
|
||||
ok=1
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "Baking in:"
|
||||
cat client/thoughtsync-android.json
|
||||
ls -l client/thoughtsync.apk
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would
|
||||
# read a sidecar describing an APK that isn't there, or an APK it
|
||||
# cannot state a version for.
|
||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
|
||||
+100
-27
@@ -1,6 +1,13 @@
|
||||
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: this is a heavy
|
||||
# Rust + AppImage build (~20-40 min) that should NOT run on backend/frontend-only
|
||||
# pushes. Scoped to desktop/** (+ this file). Produces the .deb and .AppImage.
|
||||
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: a Rust + AppImage
|
||||
# build that shouldn't run on server-only pushes. Produces the .deb and .AppImage.
|
||||
#
|
||||
# It DOES run on frontend changes. tauri's generate_context! embeds the built
|
||||
# frontend in the binary, so a frontend commit that never triggers this ships to
|
||||
# the web and silently never reaches the desktop app — and desktop, web and Android
|
||||
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
|
||||
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
|
||||
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
|
||||
# isn't there.
|
||||
#
|
||||
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
|
||||
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
|
||||
@@ -13,10 +20,23 @@ on:
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "desktop/**"
|
||||
# The desktop app embeds the frontend, and the data seam / Tauri bridge are
|
||||
# what the offline core rides on — rebuild the app when those change too.
|
||||
- "frontend/src/adapters/**"
|
||||
- "frontend/src/desktop/**"
|
||||
# The shared client core (store + sync engine) the desktop wraps. Its own
|
||||
# crate since the Android client binds the same code, so a change there is a
|
||||
# change to this app even though nothing under desktop/ moved.
|
||||
- "core/**"
|
||||
# The Android uniffi shim. It builds no desktop artifact, but it is a
|
||||
# workspace member, so this lane's `cargo clippy --all-targets` is what
|
||||
# compiles and lints it — and until the Android lane exists (M12 step 5),
|
||||
# it is the ONLY thing that does.
|
||||
- "android/**"
|
||||
# The workspace manifest and lockfile, which now live at the repo root.
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
|
||||
# the desktop binary, so any part of it changing means the shipped app is out
|
||||
# of date. Config and lockfile included — a dependency bump changes the bundle
|
||||
# as surely as a component does.
|
||||
- "frontend/**"
|
||||
- ".forgejo/workflows/desktop.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -52,13 +72,26 @@ jobs:
|
||||
run: npm ci && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it
|
||||
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead
|
||||
# of silently re-resolving. Everything after it in this job then compiles the
|
||||
# exact versions recorded in the lockfile, so the flag isn't repeated on the
|
||||
# bundle build (issue 2102).
|
||||
#
|
||||
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
||||
#
|
||||
# These three steps used to run inside the desktop crate, which was right when
|
||||
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
|
||||
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
|
||||
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
|
||||
# all. Both crates are dependencies of the desktop, so they still COMPILED —
|
||||
# which is exactly why the gap was invisible, and why a green run kept meaning
|
||||
# less than it looked like it meant.
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Test
|
||||
run: cargo test
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo test --locked --workspace
|
||||
|
||||
# Deliberately AFTER clippy + test, not before.
|
||||
#
|
||||
@@ -69,8 +102,7 @@ jobs:
|
||||
# means every push reports its real problems too. Still before the ~20-40 min
|
||||
# bundle build, so a fmt failure doesn't burn that.
|
||||
- name: Rust format check
|
||||
run: cargo fmt --check
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo fmt --all --check
|
||||
|
||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||
#
|
||||
@@ -108,6 +140,28 @@ jobs:
|
||||
- name: De-bundle AppImage graphics libraries
|
||||
run: bash desktop/packaging/appimage/debundle-graphics.sh
|
||||
|
||||
# MUST run after de-bundling, not before. The step above DELETES the AppImage
|
||||
# and repackages it, so the signature tauri produced during the build now
|
||||
# describes a file that no longer exists. Publishing that stale .sig would make
|
||||
# every Linux update fail verification — and the error names a signature
|
||||
# mismatch, which points nowhere near "a later build step rewrote the file".
|
||||
# Windows needs no equivalent: nothing post-processes the NSIS installer.
|
||||
- name: Re-sign the de-bundled AppImage
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
echo "No signing key — the build produced no signature to replace."
|
||||
exit 0
|
||||
fi
|
||||
appimage="$(find target/release/bundle/appimage -name '*.AppImage' -type f | head -1)"
|
||||
[ -n "$appimage" ] || { echo "ERROR: no AppImage found to re-sign" >&2; exit 1; }
|
||||
rm -f "$appimage.sig"
|
||||
cargo tauri signer sign "$appimage"
|
||||
[ -s "$appimage.sig" ] || { echo "ERROR: re-signing produced no .sig" >&2; exit 1; }
|
||||
echo "Re-signed $(basename "$appimage")"
|
||||
|
||||
# install.sh hands the .deb to every Debian/Ubuntu user, so the package's
|
||||
# Depends must be right BEFORE a release exists. Prints the generated
|
||||
# control file and cross-checks it against what the ELF actually needs
|
||||
@@ -131,20 +185,25 @@ jobs:
|
||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||
|
||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||
# continue-on-error: the Forgejo artifact backend may not be configured yet; a
|
||||
# failed upload must not fail the build itself.
|
||||
# Forgejo doesn't support the v4 artifact protocol (@actions/artifact v2+),
|
||||
# so pin v3, which uses the older protocol the instance accepts.
|
||||
# Mirrored action, never actions/upload-artifact: @v4+ throws
|
||||
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads
|
||||
# something Gitea stores but will never serve back (it returns artifacts only
|
||||
# through the v4 API, which filters on content_encoding='application/zip').
|
||||
# Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would
|
||||
# silently change what runs. See Scribe issues 2255 / 2270.
|
||||
# No continue-on-error: a swallowed upload failure is exactly how 110
|
||||
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
||||
- name: Upload bundles
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: thoughtsync-linux
|
||||
path: |
|
||||
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
desktop/src-tauri/target/release/bundle/deb/*.deb
|
||||
desktop/src-tauri/target/release/bundle/arch/*.pkg.tar.*
|
||||
if-no-files-found: warn
|
||||
target/release/bundle/appimage/*.AppImage
|
||||
target/release/bundle/deb/*.deb
|
||||
target/release/bundle/arch/*.pkg.tar.*
|
||||
# error, not warn: a build that bundles nothing should report as a
|
||||
# failure, not as a green run with an empty artifact.
|
||||
if-no-files-found: error
|
||||
|
||||
# Tag builds only: publish a real, versioned Fabled-Git Release with the
|
||||
# AppImage + .deb attached — the stable fetch target the install script and
|
||||
@@ -216,6 +275,15 @@ jobs:
|
||||
run: cargo tauri icon app-icon.png
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# This lane's lockfile gate (the Linux job gets it from `cargo clippy
|
||||
# --locked`). It has to be its own step here because the build is this job's
|
||||
# only crate-graph command, and discovering the drift 30 minutes into a
|
||||
# cross-compile is the expensive way to learn it. Fetching for the Windows
|
||||
# target also pre-warms exactly the crates the build will want.
|
||||
- name: Verify the lockfile and fetch dependencies
|
||||
run: cargo fetch --locked --target x86_64-pc-windows-msvc
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
|
||||
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
|
||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||
@@ -239,13 +307,15 @@ jobs:
|
||||
--config "$updater"
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# Mirrored action, never actions/upload-artifact — see the Linux job's
|
||||
# Upload bundles step for the full reasoning. Pinned by SHA because the
|
||||
# mirror auto-syncs.
|
||||
- name: Upload installer
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: thoughtsync-windows
|
||||
path: desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
if-no-files-found: warn
|
||||
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# Publishes to the SAME release as the Linux job. Safe to run twice: the
|
||||
# script reuses an existing release (409) and nullglob means each job uploads
|
||||
@@ -313,6 +383,9 @@ jobs:
|
||||
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
|
||||
export RELEASE_TAG=dev
|
||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
|
||||
# Rolling channel: drop the previous build's bundles once the manifest
|
||||
# points at this one. Nothing can reach them, and they're ~100 MB a push.
|
||||
export PRUNE_OLD_ASSETS=true
|
||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
||||
else
|
||||
export RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||
|
||||
+37
@@ -174,3 +174,40 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# Rust workspace build output (one target dir for core + desktop + android)
|
||||
/target/
|
||||
|
||||
# Android / Gradle build output.
|
||||
#
|
||||
# `local.properties` holds the machine's SDK path — it is per-workstation and
|
||||
# must never be committed; CI gets the SDK from ANDROID_HOME in the image.
|
||||
# The wrapper JAR is deliberately NOT ignored: it is how a clean checkout gets
|
||||
# the right Gradle without one installed first.
|
||||
android/.gradle/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
.kotlin/
|
||||
|
||||
# Locally-downloaded APKs for emulator/device testing.
|
||||
#
|
||||
# CI builds these and attaches them to the run as artifacts; a copy sitting in
|
||||
# the working tree is a convenience, never a source. Ignored because they are
|
||||
# ~57 MB and `git add -A` would otherwise put one in history forever.
|
||||
*.apk
|
||||
|
||||
# Signing material. NEVER committed — an Android signing key cannot be rotated
|
||||
# without the original (v3 lineage needs it), so a leaked or lost one means every
|
||||
# install has to be removed and replaced by hand. Listed before any keystore
|
||||
# exists so that generating one in this directory cannot go wrong.
|
||||
*.jks
|
||||
*.keystore
|
||||
*.p12
|
||||
*.b64
|
||||
|
||||
# The Android client CI bakes into the server image. Fetched fresh on every image
|
||||
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
|
||||
# the Dockerfile's COPY always has a directory to copy.
|
||||
client/thoughtsync.apk
|
||||
client/thoughtsync-android.json
|
||||
|
||||
Generated
+5709
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
||||
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
||||
# the Tauri desktop app and the uniffi bindings the native Android client loads.
|
||||
# Neither shim owns the core — that is the reason it is a crate at all rather than a
|
||||
# module inside the desktop app (Scribe note 2730).
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
||||
|
||||
# Shared pins, so two consumers of the core cannot drift onto different versions of
|
||||
# the same dependency and resolve differently.
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
|
||||
# Tauri's default release profile: smaller, faster shipped binaries.
|
||||
#
|
||||
# At the WORKSPACE root, not in the desktop member: cargo ignores profiles declared
|
||||
# by a non-root package, so leaving it there would silently drop lto/strip/opt-level
|
||||
# from every release build with only a warning to say so.
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
+13
@@ -24,6 +24,19 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
|
||||
# The Android client this server hands out. CI fetches the newest published build
|
||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||
# delivers a new one with no file copying by hand.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||
# token used inside a build ends up in the build context or a layer.
|
||||
#
|
||||
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||
# that step never ran. An image with no APK is a supported state — the server
|
||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||
COPY client/ src/thoughtsync/client/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
@@ -101,6 +101,11 @@ Then open `http://<host>:5000` and register — **the first account becomes the
|
||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
||||
`:<git-sha>` (immutable, for pinning / rollback).
|
||||
- **Putting it on the public internet:** there are four things to do first — close
|
||||
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
|
||||
port, and back up the attachment volume as well as the database. See
|
||||
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
|
||||
hardens on its own and what it deliberately doesn't.
|
||||
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
|
||||
browser's install button) for an app-like window. Browsers only offer install over a
|
||||
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
|
||||
|
||||
Revision ID: 0023
|
||||
Revises: 0022
|
||||
Create Date: 2026-08-22
|
||||
|
||||
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
|
||||
the note and every inbound link stops matching. The old answer was to rewrite the
|
||||
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
|
||||
title existed to hold still, untenable once a note's name is just its first body
|
||||
line (M13).
|
||||
|
||||
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
|
||||
that doesn't exist yet is a supported way to create one.
|
||||
|
||||
The backfill is safe to run bluntly because note_links is DERIVED data — every row
|
||||
is recomputed from the source body on the next save regardless.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0023"
|
||||
down_revision = "0022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"note_links",
|
||||
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_note_links_target",
|
||||
"note_links",
|
||||
"notes",
|
||||
["target_id"],
|
||||
["id"],
|
||||
# A deleted target un-resolves its inbound links rather than deleting them:
|
||||
# the link text is still in the source's body, and it should read as pointing
|
||||
# at something that isn't there — which is also what lets it re-resolve if a
|
||||
# note of that name appears again.
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
|
||||
|
||||
# Resolve what can be resolved right now, scoped to the source's owner so a link
|
||||
# can never bind to another user's note.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE note_links AS nl
|
||||
SET target_id = t.id
|
||||
FROM notes AS src, notes AS t
|
||||
WHERE nl.source_id = src.id
|
||||
AND t.owner_id = src.owner_id
|
||||
AND t.deleted_at IS NULL
|
||||
AND lower(btrim(t.display_title)) = nl.target_norm
|
||||
AND t.id <> src.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_note_links_target_id", table_name="note_links")
|
||||
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
|
||||
op.drop_column("note_links", "target_id")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""drop note_links — [[wiki-links]] are removed (note 2897)
|
||||
|
||||
Revision ID: 0024
|
||||
Revises: 0023
|
||||
Create Date: 2026-08-22
|
||||
|
||||
ThoughtSync is an intermediary surface for capture and recall; a linking system is
|
||||
organization, which is not what it is for. Backlinks, the graph and the name index
|
||||
went with it.
|
||||
|
||||
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
|
||||
than deleted. It shipped in an image and may already be applied, and removing an
|
||||
applied revision would strand a database's alembic_version pointer. So the column is
|
||||
dropped here along with the table it lived on, and the history stays honest about the
|
||||
fact that it existed for a day.
|
||||
|
||||
No down-migration data concern: note_links was always DERIVED from note bodies. The
|
||||
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
|
||||
lost by this.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0024"
|
||||
down_revision = "0023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("note_links")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"note_links",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"source_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"target_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("target_norm", sa.Text(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_note_links_target", "note_links", ["target_norm"])
|
||||
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
|
||||
@@ -0,0 +1,35 @@
|
||||
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
|
||||
|
||||
Revision ID: 0025
|
||||
Revises: 0024
|
||||
Create Date: 2026-08-22
|
||||
|
||||
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
|
||||
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
|
||||
ordinary child table keyed by note_id, serialization always emitted `items` whatever
|
||||
the kind, and the Android editor already toggled between the two losslessly. The
|
||||
storage has modelled "a body plus optional checkable items" the whole time; only the
|
||||
gates forbade it.
|
||||
|
||||
Nothing is lost. Items were already rows in their own table, and a note that was
|
||||
`kind = 'list'` keeps every one of them — it just stops being a different sort of
|
||||
thing from the note next to it.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0025"
|
||||
down_revision = "0024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("notes", "kind")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# server_default so existing rows get a value; every note comes back as 'text',
|
||||
# which is right — a restored note with items would previously have hidden its
|
||||
# body, and there is no record of which ones were once lists.
|
||||
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""drop notes.title and note_revisions.title — a note's name is its first line
|
||||
|
||||
Revision ID: 0026
|
||||
Revises: 0025
|
||||
Create Date: 2026-08-22
|
||||
|
||||
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
|
||||
non-empty line of that body, falling back to its first checklist item. There is no
|
||||
separate field to type into, and `display_title` (already persisted, already what
|
||||
search results and export filenames read) carries the name.
|
||||
|
||||
## The search vector has to be rebuilt, not just left alone
|
||||
|
||||
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
|
||||
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
|
||||
column depends on, and even if it didn't, the weighting would be wrong. So it is
|
||||
dropped and recreated over `display_title` instead, which keeps the original
|
||||
intent: the note's NAME ranks above the rest of its body.
|
||||
|
||||
Rebuilding a stored generated column re-computes every row, and the GIN index is
|
||||
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
|
||||
before running this against something large.
|
||||
|
||||
## What happens to existing titles
|
||||
|
||||
Nothing preserves them, deliberately: `display_title` was already derived from the
|
||||
title when one was set, so every note keeps the NAME it had. What is lost is the
|
||||
distinction between "this note has an explicit title" and "this note's first line is
|
||||
its name" — which is the distinction being removed.
|
||||
|
||||
Imports are the exception and are handled in code, not here: a Keep note's title, or
|
||||
one in an export taken before this, is folded in as the note's first body line rather
|
||||
than dropped (see `_create_imported_note`).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0026"
|
||||
down_revision = "0025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Order matters: the generated column depends on `title`, so it goes first.
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
op.drop_column("notes", "title")
|
||||
op.drop_column("note_revisions", "title")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
# Comes back empty. The text is not gone — it is the first line of every body —
|
||||
# but which notes once had an explicit title is not recorded anywhere.
|
||||
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
|
||||
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
# ktlint's standard function-naming rule doesn't know about Compose, where
|
||||
# PascalCase @Composable functions are the universal convention — every
|
||||
# mainstream Compose codebase would fail it. This is ktlint's own supported
|
||||
# exemption, and it mirrors the equivalent detekt override in config/detekt.yml.
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
|
||||
# 120 rather than ktlint's looser default: this is a phone UI with deeply nested
|
||||
# Compose calls, and a hard-ish ceiling is what keeps the nesting from becoming
|
||||
# unreadable rather than merely long.
|
||||
max_line_length = 120
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1,292 @@
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
// The Cargo workspace root — two levels up from android/app.
|
||||
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
||||
|
||||
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
|
||||
// covers older 32-bit hardware; the two x86 targets are what emulators run on, and
|
||||
// dropping them would make the app untestable on a desktop emulator (the reason
|
||||
// x86_64 was added to the old Tauri lane in task 1864).
|
||||
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
|
||||
/**
|
||||
* Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting
|
||||
* `.so` into jniLibs, where AGP packages it.
|
||||
*
|
||||
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
||||
* Gradle 9, and reaching for `project` at execution time is also what breaks the
|
||||
* configuration cache this build has enabled.
|
||||
*/
|
||||
abstract class CargoNdkBuild : DefaultTask() {
|
||||
@get:Inject
|
||||
abstract val execOps: ExecOperations
|
||||
|
||||
@get:InputFiles
|
||||
abstract val rustSources: ConfigurableFileCollection
|
||||
|
||||
@get:Input
|
||||
abstract val abis: ListProperty<String>
|
||||
|
||||
@get:Input
|
||||
abstract val cargoProfile: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val workspaceDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val jniLibsDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun build() {
|
||||
val args = mutableListOf("ndk")
|
||||
abis.get().forEach { abi ->
|
||||
args += "-t"
|
||||
args += abi
|
||||
}
|
||||
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi")
|
||||
// --locked so an Android build cannot silently re-resolve the workspace
|
||||
// lockfile the desktop lanes are gated on.
|
||||
args += "--locked"
|
||||
if (cargoProfile.get() == "release") args += "--release"
|
||||
|
||||
execOps.exec {
|
||||
commandLine(listOf("cargo") + args)
|
||||
workingDir = workspaceDir.get().asFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Kotlin bindings FROM the freshly built `.so`.
|
||||
*
|
||||
* `--library` mode reads uniffi's metadata straight out of the compiled artifact,
|
||||
* so the bindings can never describe a different version of the Rust than the one
|
||||
* being packaged — which is the failure the whole in-workspace generator setup
|
||||
* exists to prevent.
|
||||
*/
|
||||
abstract class UniffiBindgen : DefaultTask() {
|
||||
@get:Inject
|
||||
abstract val execOps: ExecOperations
|
||||
|
||||
@get:InputFile
|
||||
abstract val libraryFile: RegularFileProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val workspaceDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val out = outputDir.get().asFile
|
||||
out.deleteRecursively()
|
||||
out.mkdirs()
|
||||
execOps.exec {
|
||||
commandLine(
|
||||
"cargo",
|
||||
"run",
|
||||
"--locked",
|
||||
"-p",
|
||||
"thoughtsync-uniffi-bindgen",
|
||||
"--",
|
||||
"generate",
|
||||
"--library",
|
||||
libraryFile.get().asFile.absolutePath,
|
||||
"--language",
|
||||
"kotlin",
|
||||
"--out-dir",
|
||||
out.absolutePath,
|
||||
)
|
||||
workingDir = workspaceDir.get().asFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the Rust that actually affects the .so. Deliberately NOT the workspace
|
||||
// directory: that would make Gradle hash target/, which is gigabytes.
|
||||
val rustInputs =
|
||||
files(
|
||||
workspaceRoot.dir("core/src"),
|
||||
workspaceRoot.dir("android/ffi/src"),
|
||||
workspaceRoot.file("core/Cargo.toml"),
|
||||
workspaceRoot.file("android/ffi/Cargo.toml"),
|
||||
workspaceRoot.file("Cargo.toml"),
|
||||
workspaceRoot.file("Cargo.lock"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Which Cargo profile the `.so` is built with.
|
||||
*
|
||||
* A property rather than a debug/release task PAIR, deliberately. This runner has
|
||||
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
|
||||
* cross-compile of four ABIs costs about four minutes — and a lane that both
|
||||
* type-checks and packages would pay that twice if the two used different
|
||||
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
|
||||
* the run.
|
||||
*
|
||||
* CI currently passes `debug` even for a release APK, which is not where this
|
||||
* should end up: an unoptimised store and sync engine is a real difference on a
|
||||
* phone, not a theoretical one. The blocker is that the workspace's release
|
||||
* profile sets `strip = true`, which removes the symbols uniffi reads its
|
||||
* interface metadata from — `generateUniffiBindings` then fails with "No UniFFI
|
||||
* metadata found" (run 4077). Fixing it means either an Android-specific profile
|
||||
* that keeps symbols or generating the bindings from a separate unstripped
|
||||
* build, and neither is worth holding signed APKs up for. Scribe #2810.
|
||||
*/
|
||||
val rustProfile =
|
||||
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||
?: "debug"
|
||||
|
||||
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||
|
||||
val cargoNdk =
|
||||
tasks.register<CargoNdkBuild>("cargoNdk") {
|
||||
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
|
||||
rustSources.from(rustInputs)
|
||||
abis.set(androidAbis)
|
||||
cargoProfile.set(rustProfile)
|
||||
workspaceDir.set(workspaceRoot)
|
||||
jniLibsDir.set(jniLibsOut)
|
||||
}
|
||||
|
||||
val generateBindings =
|
||||
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
||||
description = "Generate the Kotlin bindings from the compiled .so."
|
||||
dependsOn(cargoNdk)
|
||||
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
||||
// reading one is cheaper than reading four.
|
||||
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
|
||||
workspaceDir.set(workspaceRoot)
|
||||
outputDir.set(bindingsOut)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fabledsword.thoughtsync"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.fabledsword.thoughtsync"
|
||||
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
||||
// room to spare.
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
// Injected by CI from the git tag + commit count for a release; "dev"
|
||||
// locally so the About screen reads honestly rather than claiming 1.0.
|
||||
val nameOverride =
|
||||
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val codeOverride =
|
||||
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
versionCode = codeOverride ?: 1
|
||||
versionName = nameOverride ?: "dev"
|
||||
|
||||
// Package ONLY the ABIs we build for.
|
||||
//
|
||||
// Without this the APK also carries armeabi, mips and mips64 — dead
|
||||
// architectures Android dropped years ago, which arrive because JNA's
|
||||
// .aar still ships a libjnidispatch.so for each. They can never be
|
||||
// loaded on any device this app supports, so they are pure payload.
|
||||
ndk {
|
||||
abiFilters += androidAbis
|
||||
}
|
||||
}
|
||||
|
||||
// The signing key reaches this build only through the environment: CI decodes
|
||||
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
|
||||
// It is never in the repo and never in this file. Generated by the operator
|
||||
// and never seen by an agent session, because an Android signing key cannot be
|
||||
// rotated without the original — v3 lineage needs it — so a leaked or lost one
|
||||
// means every install has to be removed and replaced by hand.
|
||||
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
||||
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
||||
|
||||
signingConfigs {
|
||||
if (keystoreFile != null && keystorePassword != null) {
|
||||
create("release") {
|
||||
storeFile = File(keystoreFile)
|
||||
storePassword = keystorePassword
|
||||
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
||||
// this app and is written into the certificate every install
|
||||
// already carries. Hiding it would buy nothing and stop this file
|
||||
// describing its own signing setup.
|
||||
keyAlias = "thoughtsync"
|
||||
// PKCS12 cannot hold a key password distinct from the store
|
||||
// password — keytool refuses to set one — so this is the same
|
||||
// value by necessity rather than by shortcut.
|
||||
keyPassword = keystorePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Null when no keystore reached this build, which leaves the APK
|
||||
// unsigned and therefore uninstallable. `android.yml` builds debug in
|
||||
// that case rather than producing an artifact nobody can put on a
|
||||
// phone.
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `.so` and the generated bindings as GENERATED sources.
|
||||
*
|
||||
* NOT `sourceSets { ... srcDir(task) }`: AGP 9 rejects a Provider there outright,
|
||||
* because it cannot tell whether the directory holds generated (read-only) or
|
||||
* hand-written (read-write) files — a distinction the IDE needs. The Variant API
|
||||
* is the supported route and, unlike a bare path, `addGeneratedSourceDirectory`
|
||||
* carries the task dependency, so Kotlin cannot compile before the bindings
|
||||
* exist and the APK cannot package a stale `.so`.
|
||||
*/
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
|
||||
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
|
||||
// Required by the uniffi bindings — see the catalog note on the @aar
|
||||
// classifier; the plain jar builds fine and fails at runtime.
|
||||
implementation(variantOf(libs.jna) { artifactType("aar") })
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material.icons.core)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# JNA reaches the native library reflectively, so R8 must not rename or strip
|
||||
# either it or the uniffi bindings that ride on it. Without these a minified
|
||||
# build fails at runtime with UnsatisfiedLinkError — and only in release, which
|
||||
# is the worst possible time to learn it.
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
||||
-keep class com.fabledsword.thoughtsync.core.** { *; }
|
||||
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!--
|
||||
INTERNET is requested but nothing uses it until the user links a server.
|
||||
The app is local-first: the store, capture and the whole board work with
|
||||
this permission never exercised.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!--
|
||||
Four more permissions are NOT declared here and still reach the merged
|
||||
manifest, contributed by WorkManager for the automatic sync:
|
||||
|
||||
RECEIVE_BOOT_COMPLETED reschedules the periodic sync after a restart,
|
||||
instead of it silently stopping until the app is
|
||||
next opened by hand
|
||||
ACCESS_NETWORK_STATE evaluates the "needs a network" constraint, so a
|
||||
run is not attempted with no route to the server
|
||||
WAKE_LOCK holds the device awake for the seconds a sync
|
||||
takes, so it is not suspended mid-request
|
||||
FOREGROUND_SERVICE used only for expedited work; nothing here asks
|
||||
for it, and it arrives with the library
|
||||
|
||||
Verified against the built APK's merged manifest, not assumed. Noted here
|
||||
because all four appear in the app's permission list and nothing else in
|
||||
this file would explain where they came from.
|
||||
-->
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
Reminders.
|
||||
|
||||
POST_NOTIFICATIONS is a runtime permission from API 33. It is asked for in
|
||||
context — the first time the app opens holding a reminder that could fire,
|
||||
never at launch on an empty board, where there would be nothing to explain
|
||||
why it is being asked.
|
||||
|
||||
SCHEDULE_EXACT_ALARM rather than USE_EXACT_ALARM. USE_EXACT_ALARM is granted
|
||||
at install with no prompt, and is reserved for apps whose whole purpose is an
|
||||
alarm clock or calendar; a note app claiming it would be claiming something
|
||||
untrue. SCHEDULE_EXACT_ALARM is the one the person can grant or refuse, and
|
||||
refusing costs precision, not the feature — see Reminders.scheduleNext.
|
||||
|
||||
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
|
||||
declared here too because ReminderReceiver now depends on it directly. A
|
||||
permission this file relies on should be visible in this file.
|
||||
-->
|
||||
<!--
|
||||
Updating this app from the server it syncs with (M12 step 7).
|
||||
|
||||
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
|
||||
all. It is NOT what makes an install look suspicious to on-device heuristics
|
||||
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
|
||||
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
|
||||
2437. The person must additionally grant "install unknown apps" in system
|
||||
settings; the update card asks before downloading anything.
|
||||
|
||||
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
|
||||
confirmation on the UPDATE path, and only there — Android will not let an app
|
||||
silently put a NEW package on a device, which is correct. It also only applies
|
||||
when the new build is signed with the same key as the installed one, which is
|
||||
why signing had to land before any of this could work.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".ThoughtSyncApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ThoughtSync"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.ThoughtSync">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Not exported: every intent that reaches it is one this app created, with
|
||||
an explicit component. Exporting would let any app on the device mark
|
||||
someone's reminders as done.
|
||||
|
||||
The two system broadcasts are the exception and need the filter, because
|
||||
the system is the sender. Both exist for the same reason — pending alarms
|
||||
do not survive either a reboot or an app update, so without this a phone
|
||||
that restarts overnight would quietly stop reminding anyone of anything.
|
||||
-->
|
||||
<!--
|
||||
Where the system reports what happened to an install we committed. Not
|
||||
exported: the only sender is the PendingIntent this app handed to
|
||||
PackageInstaller. Without it a failed install would be indistinguishable
|
||||
from someone declining the dialog (Scribe #2438).
|
||||
-->
|
||||
<receiver
|
||||
android:name=".UpdateReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ReminderReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Replacing this app with a newer build of itself.
|
||||
*
|
||||
* ## A PackageInstaller session, not an install intent
|
||||
*
|
||||
* The obvious route — `ACTION_VIEW` on the APK with
|
||||
* `application/vnd.android.package-archive` — is the one on-device install
|
||||
* heuristics are tuned against, and it is what produces the "bypassing Android
|
||||
* security" warning the operator saw on Minstrel (Scribe note 2437). It also never
|
||||
* tells the OS that this app is the legitimate updater of its own package, and it
|
||||
* returns nothing: a failed install is indistinguishable from a person dismissing
|
||||
* the dialog.
|
||||
*
|
||||
* A session says who is doing what. On Android 12+ it can also declare that no user
|
||||
* action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` —
|
||||
* removes the confirmation dialog entirely on the UPDATE path. Not on a first
|
||||
* install: the OS will not let an app quietly put a NEW package on a device, which
|
||||
* is right.
|
||||
*
|
||||
* Two things from that same research that are NOT done here, deliberately:
|
||||
* `setRequestUpdateOwnership` was chased and turned out to be a red herring, and
|
||||
* `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
|
||||
* too. The mechanism was the whole difference.
|
||||
*
|
||||
* ## The outcome comes back
|
||||
*
|
||||
* `commit` takes an `IntentSender`; the system reports the result to
|
||||
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
|
||||
*/
|
||||
object AppUpdate {
|
||||
private const val TAG = "ThoughtSyncUpdate"
|
||||
|
||||
/** This build's versionCode — what the server's is compared against. */
|
||||
fun installedVersionCode(context: Context): Long =
|
||||
runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
|
||||
}.getOrDefault(0L)
|
||||
|
||||
/**
|
||||
* Whether this app may install packages at all.
|
||||
*
|
||||
* A separate grant from anything in the manifest, and one only the person can
|
||||
* give. Checked before offering an update rather than after downloading 55 MiB.
|
||||
*/
|
||||
fun canInstall(context: Context): Boolean = context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
/** The settings page where that grant lives, scoped to this app. */
|
||||
fun installPermissionSettings(context: Context): Intent =
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
|
||||
.setData(Uri.fromParts("package", context.packageName, null))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
||||
|
||||
/**
|
||||
* Hand the APK to the system installer.
|
||||
*
|
||||
* Streamed into the session rather than passed as a path or a content URI —
|
||||
* the session takes bytes, which is also why no FileProvider is needed here.
|
||||
*
|
||||
* Returns the failure to show, or null when the install was handed over
|
||||
* successfully. "Handed over" is the honest word: the real outcome arrives
|
||||
* later at [UpdateReceiver], because a commit that the system accepts can still
|
||||
* fail afterwards.
|
||||
*/
|
||||
fun install(
|
||||
context: Context,
|
||||
apk: File,
|
||||
): String? {
|
||||
val installer = context.packageManager.packageInstaller
|
||||
var sessionId = -1
|
||||
return try {
|
||||
val params =
|
||||
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Only honoured on an UPDATE of an app signed with the same key —
|
||||
// exactly our case, and the reason the signing work had to land
|
||||
// first. Android ignores it for anything else rather than failing,
|
||||
// so there is no need to guard on which it is.
|
||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
|
||||
sessionId = installer.createSession(params)
|
||||
installer.openSession(sessionId).use { session ->
|
||||
writeApk(session, apk)
|
||||
session.commit(statusSender(context))
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
// Broad on purpose: createSession throws IOException, openWrite throws,
|
||||
// and the framework raises SecurityException for a revoked grant. All of
|
||||
// them mean one thing to the person — it did not install — and none of
|
||||
// them should take the app down.
|
||||
Log.w(TAG, "could not start the install session", e)
|
||||
if (sessionId != -1) runCatching { installer.abandonSession(sessionId) }
|
||||
e.message ?: "The update could not be installed."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the APK into the session.
|
||||
*
|
||||
* Its own function only because the two nested `use` blocks read badly inline —
|
||||
* and detekt agreed, which is fair: a stream inside a session inside a try is
|
||||
* three things to hold at once.
|
||||
*/
|
||||
private fun writeApk(
|
||||
session: PackageInstaller.Session,
|
||||
apk: File,
|
||||
) {
|
||||
session.openWrite(WRITE_NAME, 0, apk.length()).use { out ->
|
||||
apk.inputStream().use { it.copyTo(out) }
|
||||
// Before close: the session must have the bytes on disk, not sitting in
|
||||
// a buffer, or commit can be handed a short file.
|
||||
session.fsync(out)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusSender(context: Context): IntentSender {
|
||||
val intent =
|
||||
Intent(context, UpdateReceiver::class.java).setAction(UpdateReceiver.ACTION_INSTALLED)
|
||||
// MUTABLE, and this is the one place it is correct: the system fills the
|
||||
// result extras in before delivering it. An immutable one would arrive with
|
||||
// no status at all.
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
android.app.PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
return android.app.PendingIntent
|
||||
.getBroadcast(context, 0, intent, flags)
|
||||
.intentSender
|
||||
}
|
||||
|
||||
private const val WRITE_NAME = "thoughtsync-update"
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncState
|
||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
||||
import com.fabledsword.thoughtsync.ui.olderThan
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* The note a reminder notification asked for, waiting to be opened.
|
||||
*
|
||||
* Held on the Activity rather than passed to `setContent` once, because a tap
|
||||
* on a notification while the app is already running arrives at [onNewIntent],
|
||||
* not [onCreate] — the composition is long since built by then and the only
|
||||
* way in is a piece of state it is already reading.
|
||||
*/
|
||||
private val requestedNote = mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val app = application as ThoughtSyncApplication
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
|
||||
setContent {
|
||||
ThoughtSyncTheme {
|
||||
val core = app.core
|
||||
if (core == null) {
|
||||
// The store never opened. There is no board to show and no
|
||||
// action that would help, so say what happened plainly rather
|
||||
// than render an empty board that looks like data loss.
|
||||
StoreUnavailableScreen(reason = app.openFailure)
|
||||
} else {
|
||||
App(core, requestedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the note a notification asked for, and CONSUME it.
|
||||
*
|
||||
* The removal is the point. The Activity keeps the intent it was launched
|
||||
* with, so without this, rotating the phone would replay it — reopening a note
|
||||
* the person had tapped through and then closed, over and over, with no way to
|
||||
* tell where it kept coming from.
|
||||
*/
|
||||
private fun takeRequestedNote(intent: Intent?): String? {
|
||||
val id = intent?.getStringExtra(Reminders.EXTRA_NOTE_ID) ?: return null
|
||||
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
/** Which screen is up. Exactly one at a time. */
|
||||
private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
|
||||
/**
|
||||
* The whole app, once the store is open.
|
||||
*
|
||||
* One screen composed at a time, never stacked: the editor and the sync screen
|
||||
* both cover the display completely, so keeping the board's two-column grid
|
||||
* measuring and recomposing underneath one would be pure waste.
|
||||
*
|
||||
* Still no navigation library. Three destinations, each entered from exactly one
|
||||
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||
* state that actually matters (which note is open, whether this device is linked)
|
||||
* already lives in view models.
|
||||
*/
|
||||
@Composable
|
||||
private fun App(
|
||||
core: ThoughtSync,
|
||||
requestedNote: MutableState<String?>,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val board: BoardViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
BoardViewModel.factory(core) {
|
||||
// Any store write can have moved the next reminder. Called on
|
||||
// the IO dispatcher by the view model, which is where it has to
|
||||
// be — this reads every note carrying a reminder.
|
||||
Reminders.refresh(context, core)
|
||||
},
|
||||
)
|
||||
|
||||
// Consumed, not just read: without clearing it, every later recomposition
|
||||
// would reopen the same note and make the editor impossible to leave.
|
||||
LaunchedEffect(requestedNote.value) {
|
||||
requestedNote.value?.let {
|
||||
board.openNoteById(it)
|
||||
requestedNote.value = null
|
||||
}
|
||||
}
|
||||
|
||||
ReminderAlarms(core)
|
||||
// A pull can rewrite every note the board is holding, so a sync that changed
|
||||
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||
val sync: SyncViewModel =
|
||||
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||
|
||||
// Sheet and screen visibility are view STATE, not view-model state: they are
|
||||
// about what is on the display, and nothing in the store cares.
|
||||
//
|
||||
// Saveable, though: `remember` alone meant rotating the phone closed whatever
|
||||
// was open and took the half-written note in the capture sheet with it. The
|
||||
// editor never had that problem because the note it is on lives in a view
|
||||
// model; these two are the only screen state that did not.
|
||||
var composing by rememberSaveable { mutableStateOf(false) }
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
val settings = remember(context) { SyncSettings(context) }
|
||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||
|
||||
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||
|
||||
val editing = board.state.editing
|
||||
val screen =
|
||||
when {
|
||||
showingSync -> Screen.SYNC
|
||||
editing != null -> Screen.EDITOR
|
||||
else -> Screen.BOARD
|
||||
}
|
||||
|
||||
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,
|
||||
automatic = automatic,
|
||||
onAutomaticChange = {
|
||||
automatic = it
|
||||
settings.automatic = it
|
||||
},
|
||||
update = update.state,
|
||||
onCheckUpdate = update::check,
|
||||
onInstallUpdate = update::downloadAndInstall,
|
||||
onDismissUpdateError = update::dismissError,
|
||||
onInstallOutcome = update::consumeInstallOutcome,
|
||||
)
|
||||
|
||||
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,
|
||||
sync =
|
||||
BoardSync(
|
||||
summary = syncSummary(sync),
|
||||
error = sync.state.syncError,
|
||||
refreshing = sync.state.syncing,
|
||||
// Only a linked device has anywhere to pull FROM. The
|
||||
// board never learns this itself — one owner for the fact.
|
||||
canRefresh = sync.state.linked,
|
||||
onRefresh = sync::syncNow,
|
||||
onDismissError = sync::dismissSyncError,
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onSearch = board::search,
|
||||
onCompose = { composing = true },
|
||||
onDismissError = board::dismissError,
|
||||
)
|
||||
|
||||
if (composing) {
|
||||
ComposeSheet(
|
||||
saving = board.state.saving,
|
||||
onDismiss = { composing = false },
|
||||
onSave = { content ->
|
||||
board.create(content)
|
||||
composing = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sync screen has no back handler of its own, so one lives here. The
|
||||
// editor keeps its own, because it has to save the open note before leaving.
|
||||
BackHandler(enabled = showingSync) { showingSync = false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeping the alarm current, and asking to be allowed to ring it.
|
||||
*
|
||||
* The refresh runs on every return to the foreground rather than once at launch:
|
||||
* a reminder can have been set on the desktop and pulled in while this app was
|
||||
* backgrounded, and the alarm is derived from the store, not from what the UI last
|
||||
* saw. It is cheap and idempotent by construction — see [Reminders.refresh].
|
||||
*
|
||||
* The permission is asked for on the first launch where a reminder actually
|
||||
* exists. Android gives an app essentially one chance at this dialog, so spending
|
||||
* it at first launch on an empty board — before the person has any idea what
|
||||
* notifications this app would send — is spending it on nothing.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReminderAlarms(core: ThoughtSync) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Off the main thread: this reads every note that has a reminder, and a phone
|
||||
// holding a few hundred would drop frames doing it during a resume.
|
||||
val refresh = { scope.launch(Dispatchers.IO) { Reminders.refresh(context, core) } }
|
||||
|
||||
val prompt =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
// Whatever the answer, re-derive: if it was yes, the reminders that
|
||||
// could not be shown a moment ago can be shown now.
|
||||
refresh()
|
||||
}
|
||||
|
||||
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
|
||||
// it, notifications are granted at install and there is nothing to ask.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
|
||||
val due = withContext(Dispatchers.IO) { Reminders.promptToNotifyDue(context, core) }
|
||||
if (due) {
|
||||
Reminders.markPromptShown(context)
|
||||
prompt.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncing without being asked.
|
||||
*
|
||||
* Three moments, and they are not the same job:
|
||||
*
|
||||
* - **Coming to the front.** Someone opening the app expects what they are
|
||||
* looking at to be true. Rate-limited by [STALE_MINUTES] so flicking between
|
||||
* two apps is not a request for fresh notes.
|
||||
* - **Going away with unsent work.** Handed to WorkManager rather than run
|
||||
* inline, because the process is about to stop being a priority and a sync
|
||||
* started here would be killed halfway.
|
||||
* - **Every fifteen minutes.** The background heartbeat, so a phone in a pocket
|
||||
* is roughly current before it is picked up.
|
||||
*
|
||||
* Being unlinked or having the switch off makes all three no-ops, and cancels the
|
||||
* scheduled work rather than merely skipping it.
|
||||
*/
|
||||
@Composable
|
||||
private fun AutomaticSync(
|
||||
state: SyncState,
|
||||
enabled: Boolean,
|
||||
onSync: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// DECLARED as a function of two facts rather than toggled from the places
|
||||
// that change them. There are four routes to "should not be syncing on its
|
||||
// own" — never linked, just unlinked, switch off, switch off then unlink —
|
||||
// and a call at each is four chances to leave a phone quietly syncing after
|
||||
// it was told to stop.
|
||||
LaunchedEffect(state.linked, enabled) {
|
||||
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
|
||||
}
|
||||
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(
|
||||
onForeground = { wanted = true },
|
||||
onBackground = {
|
||||
// Unsent work follows the person out of the app. Without this, a note
|
||||
// written on a phone that then goes into a pocket for the night does
|
||||
// not reach the desktop until the app is opened again by hand.
|
||||
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
|
||||
},
|
||||
)
|
||||
|
||||
// Keyed on `loading` so the decision waits for the stored link to be READ. At
|
||||
// first composition `linked` is still false because nothing has looked in the
|
||||
// database yet, and acting on that would skip the sync on every cold start.
|
||||
LaunchedEffect(wanted, state.loading) {
|
||||
if (!wanted || state.loading) return@LaunchedEffect
|
||||
// Consumed here, so this fires exactly once per trip to the foreground
|
||||
// however many times the effect restarts. Writing a key from inside the
|
||||
// effect does restart it — but there is no suspension point between here
|
||||
// and the call below, so the block runs to completion before the
|
||||
// recomposition that would cancel it can be scheduled.
|
||||
wanted = false
|
||||
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
|
||||
if (enabled && state.linked && worthIt) onSync()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How stale the last sync has to be before opening the app triggers another.
|
||||
*
|
||||
* Not zero. Stepping out to copy a link and stepping back is not a request for
|
||||
* fresh notes, and syncing on every app switch spends someone's mobile data to
|
||||
* tell them what they are already looking at. Five minutes is short enough that
|
||||
* coming back to the phone after doing something else gets current data, and
|
||||
* long enough that flicking between two apps does not.
|
||||
*
|
||||
* Unsent local changes bypass this entirely — those go out at the first chance.
|
||||
*/
|
||||
private const val STALE_MINUTES = 5L
|
||||
|
||||
/**
|
||||
* One line of sync state for the drawer, or null when there is nothing to say.
|
||||
*
|
||||
* Deliberately silent while the status is still loading and when the device is
|
||||
* simply unlinked-and-idle — an "Off" badge on a local-first app would frame its
|
||||
* normal resting state as something switched off.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* What a due reminder looks like in the shade.
|
||||
*
|
||||
* Split from [Reminders] because the two answer different questions and change for
|
||||
* different reasons: that one decides WHEN something should be said, this one
|
||||
* decides how it is said and what can be done about it without opening the app.
|
||||
*/
|
||||
internal object ReminderNotification {
|
||||
fun ensureChannel(context: Context) {
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL,
|
||||
context.getString(R.string.reminder_channel),
|
||||
// HIGH so a reminder can interrupt. Someone who asked to be
|
||||
// reminded at a time has already said this may interrupt them;
|
||||
// DEFAULT would leave it silent in the shade until next unlock.
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { description = context.getString(R.string.reminder_channel_description) }
|
||||
context
|
||||
.getSystemService(NotificationManager::class.java)
|
||||
?.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/** Post one reminder. Returns whether it actually reached the shade. */
|
||||
fun show(
|
||||
context: Context,
|
||||
note: Note,
|
||||
): Boolean {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
// Not marked as announced when this is false, so a reminder is not silently
|
||||
// burned by being "delivered" to a device that cannot show it — turning
|
||||
// notifications on later still surfaces it.
|
||||
if (!manager.areNotificationsEnabled()) return false
|
||||
|
||||
val body = note.body.trim().takeIf { it.isNotEmpty() && it != note.displayTitle }
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(context, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(note.displayTitle)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openIntent(context, note))
|
||||
.addAction(
|
||||
0,
|
||||
context.getString(R.string.reminder_done),
|
||||
action(context, note, ReminderReceiver.ACTION_DONE),
|
||||
).addAction(
|
||||
0,
|
||||
context.getString(R.string.reminder_snooze_hour),
|
||||
action(context, note, ReminderReceiver.ACTION_SNOOZE),
|
||||
)
|
||||
|
||||
if (body != null) {
|
||||
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
manager.notify(note.id.hashCode(), builder.build())
|
||||
true
|
||||
}.getOrElse {
|
||||
// POST_NOTIFICATIONS can be revoked between the check and the post.
|
||||
Log.w(TAG, "could not post reminder", it)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Take a reminder off the shade, once it has been acted on. */
|
||||
fun dismiss(
|
||||
context: Context,
|
||||
noteId: String,
|
||||
) = NotificationManagerCompat.from(context).cancel(noteId.hashCode())
|
||||
|
||||
private fun openIntent(
|
||||
context: Context,
|
||||
note: Note,
|
||||
): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
note.id.hashCode(),
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.putExtra(Reminders.EXTRA_NOTE_ID, note.id)
|
||||
// Reuse the running task rather than stacking a second copy of the
|
||||
// app on top of itself; MainActivity picks the id up in onNewIntent.
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun action(
|
||||
context: Context,
|
||||
note: Note,
|
||||
what: String,
|
||||
): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
// Distinct per note AND per action, or the two would share one
|
||||
// PendingIntent and Snooze would quietly perform Done.
|
||||
(note.id + what).hashCode(),
|
||||
Intent(context, ReminderReceiver::class.java)
|
||||
.setAction(what)
|
||||
.putExtra(Reminders.EXTRA_NOTE_ID, note.id),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private const val CHANNEL = "reminders"
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Everything that happens to a reminder while the app is not on screen.
|
||||
*
|
||||
* Four arrivals, one ending: whatever came in, the reminder picture is recomputed
|
||||
* and the next alarm is set. That is deliberate — it means no path here has to
|
||||
* remember to reschedule, and the one that fires an alarm cannot leave the device
|
||||
* with no alarm pending.
|
||||
*
|
||||
* - **[ACTION_DUE]** — the alarm went off. Announce what is due.
|
||||
* - **[ACTION_DONE] / [ACTION_SNOOZE]** — a notification button. Write it to the
|
||||
* store, drop the notification.
|
||||
* - **`BOOT_COMPLETED`** — alarms do not survive a restart, so every reminder on
|
||||
* the device would silently stop existing without this.
|
||||
* - **`MY_PACKAGE_REPLACED`** — an app update cancels them the same way. This
|
||||
* device installs by APK from its own server, so updates are routine.
|
||||
*
|
||||
* ## Threading
|
||||
*
|
||||
* `onReceive` runs on the main thread and the store is blocking SQLite, so the
|
||||
* work goes to [Dispatchers.IO] under [goAsync]. Without `goAsync` the process
|
||||
* becomes killable the moment `onReceive` returns, which for a reminder firing at
|
||||
* 3am is precisely when nothing is holding it up.
|
||||
*/
|
||||
class ReminderReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
|
||||
val action = intent.action
|
||||
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
|
||||
val app = context.applicationContext
|
||||
|
||||
val pending = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
when (action) {
|
||||
ACTION_DONE -> noteId?.let { Reminders.complete(app, core, it) }
|
||||
ACTION_SNOOZE -> noteId?.let { Reminders.snooze(app, core, it) }
|
||||
// The alarm and the two system broadcasts all want the same
|
||||
// thing, which is simply: look at the store and act on it.
|
||||
else -> Unit
|
||||
}
|
||||
Reminders.refresh(app, core)
|
||||
} catch (e: Exception) {
|
||||
// Broad on purpose. Nobody is present, so an escaping exception is
|
||||
// a crash report for something the person never initiated — and
|
||||
// every path in here has already logged its own failure.
|
||||
Log.w(TAG, "reminder broadcast failed: $action", e)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
|
||||
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
|
||||
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* Getting a reminder in front of someone at the time they asked for.
|
||||
*
|
||||
* ## One alarm, not one per reminder
|
||||
*
|
||||
* Only the EARLIEST future reminder is ever scheduled. When it fires, everything
|
||||
* now due is announced and the next one is scheduled. A hundred reminders cost one
|
||||
* alarm, and there is no bookkeeping to get wrong when a note is edited on another
|
||||
* device and arrives by sync — [refresh] recomputes the whole picture from the
|
||||
* store every time.
|
||||
*
|
||||
* ## AlarmManager, not WorkManager
|
||||
*
|
||||
* The background sync runs on WorkManager and is right to: nobody minds whether it
|
||||
* happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic
|
||||
* floor is fifteen minutes and it batches work into maintenance windows, so
|
||||
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
|
||||
* it is a rebuke.
|
||||
*
|
||||
* Exactness is asked for and not depended on: on Android 12+ it is a permission
|
||||
* the person can refuse, and refusing drops this to an inexact alarm rather than
|
||||
* to nothing. A reminder a few minutes late still beats no reminder, and pestering
|
||||
* someone into a settings screen before the feature works at all is the coercion
|
||||
* this product does not do.
|
||||
*/
|
||||
object Reminders {
|
||||
const val EXTRA_NOTE_ID = "note_id"
|
||||
|
||||
/**
|
||||
* How long after its time a missed reminder is still worth announcing.
|
||||
*
|
||||
* The web uses fifteen minutes, because a tab that is open has been checking
|
||||
* every forty-five seconds and anything older than that was almost certainly
|
||||
* already seen. A phone can be switched off all night, so the equivalent
|
||||
* question here — "could this plausibly not have been seen yet?" — has a much
|
||||
* longer answer. Beyond a day it stops being a reminder and starts being
|
||||
* archaeology; the note is still on the board, still marked overdue in red.
|
||||
*/
|
||||
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
private const val SNOOZE_MINUTES = 60L
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
|
||||
/**
|
||||
* Announce what is due, then schedule the next one.
|
||||
*
|
||||
* Safe to call as often as anything might have changed — after an edit, after
|
||||
* a sync, at launch, at boot. It reads the whole reminder set each time and
|
||||
* derives everything from it, so there is no incremental state to drift.
|
||||
*/
|
||||
fun refresh(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
) {
|
||||
ReminderNotification.ensureChannel(context)
|
||||
val notes =
|
||||
runCatching { core.reminderNotes() }
|
||||
.onFailure { Log.w(TAG, "could not read reminders", it) }
|
||||
.getOrElse { return }
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val announced = Announced(context)
|
||||
// Intersecting with what still exists prunes the record in the same step:
|
||||
// a reminder that was completed, snoozed to a new time or deleted drops out
|
||||
// on its own, so this set cannot grow without bound.
|
||||
val live = notes.mapNotNull { key(it) }.toSet()
|
||||
val seen = announced.keys().intersect(live).toMutableSet()
|
||||
|
||||
val due = notes.filter { at(it)?.let { ms -> ms <= now } == true }
|
||||
if (!announced.primed) {
|
||||
// First run on this device. Adopt everything already overdue SILENTLY:
|
||||
// the storm case is linking a server and pulling months of history, and
|
||||
// a hundred notifications the moment someone signs in is a good way to
|
||||
// have them turn the feature off before it has ever been useful.
|
||||
due.forEach { note -> key(note)?.let { seen += it } }
|
||||
} else {
|
||||
due.forEach { note ->
|
||||
val k = key(note) ?: return@forEach
|
||||
val overdueBy = now - (at(note) ?: return@forEach)
|
||||
if (k !in seen && overdueBy <= MISSED_WINDOW_MS && ReminderNotification.show(context, note)) {
|
||||
seen += k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
announced.write(seen)
|
||||
scheduleNext(context, notes, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether it is worth putting Android's notification prompt in front of someone.
|
||||
*
|
||||
* True only when there is a reminder that could actually fire and we have not
|
||||
* asked before. Asking at launch on an empty board would be a dialog with no
|
||||
* visible cause, which is how people learn to dismiss dialogs unread; asking
|
||||
* again after a refusal is nagging, and the Reminders view carries a standing
|
||||
* notice for anyone who changes their mind.
|
||||
*/
|
||||
fun promptToNotifyDue(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
): Boolean =
|
||||
!Announced(context).askedToNotify &&
|
||||
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||
runCatching { core.reminderNotes().isNotEmpty() }.getOrDefault(false)
|
||||
|
||||
/** Remember that Android's prompt has been shown, whatever the answer was. */
|
||||
fun markPromptShown(context: Context) = Announced(context).markAsked()
|
||||
|
||||
/** Clear the reminder, as the notification's Done action. */
|
||||
fun complete(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
noteId: String,
|
||||
) {
|
||||
runCatching { core.completeReminder(noteId) }
|
||||
.onFailure { Log.w(TAG, "could not complete reminder", it) }
|
||||
ReminderNotification.dismiss(context, noteId)
|
||||
}
|
||||
|
||||
/** Push the reminder an hour out, as the notification's Snooze action. */
|
||||
fun snooze(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
noteId: String,
|
||||
) {
|
||||
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
|
||||
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
|
||||
ReminderNotification.dismiss(context, noteId)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── scheduling ───────────────────────────────
|
||||
|
||||
private fun scheduleNext(
|
||||
context: Context,
|
||||
notes: List<Note>,
|
||||
now: Long,
|
||||
) {
|
||||
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||
val fire =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(context, ReminderReceiver::class.java).setAction(ReminderReceiver.ACTION_DUE),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val next = notes.mapNotNull { at(it) }.filter { it > now }.minOrNull()
|
||||
if (next == null) {
|
||||
alarms.cancel(fire)
|
||||
return
|
||||
}
|
||||
|
||||
// RTC_WAKEUP: reminders are wall-clock times, and the point is to wake a
|
||||
// sleeping phone. ELAPSED_REALTIME would drift against the clock the person
|
||||
// actually set the reminder against.
|
||||
runCatching {
|
||||
if (canBeExact(alarms)) {
|
||||
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
|
||||
} else {
|
||||
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
|
||||
}
|
||||
}.onFailure {
|
||||
// setExact can still throw if the permission was revoked between the
|
||||
// check and the call. Falling back beats losing the reminder entirely.
|
||||
Log.w(TAG, "exact alarm refused, falling back to inexact", it)
|
||||
runCatching { alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device will let us fire at the exact minute.
|
||||
*
|
||||
* Below Android 12 there was no permission and exact alarms always worked.
|
||||
* From 12 it is grantable and from 13 it is denied by default, so this is a
|
||||
* question with a real answer rather than a formality.
|
||||
*/
|
||||
fun canBeExact(alarms: AlarmManager): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
|
||||
|
||||
// ────────────────────────────── bookkeeping ──────────────────────────────
|
||||
|
||||
/** Epoch millis of a note's reminder, or null if it has none we can read. */
|
||||
private fun at(note: Note): Long? =
|
||||
note.remindAt?.let {
|
||||
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of one OCCURRENCE, not of the note.
|
||||
*
|
||||
* The time is part of it so that snoozing — which rewrites `remind_at` — is a
|
||||
* new thing to announce rather than one already dealt with. Same key the web
|
||||
* store uses, for the same reason.
|
||||
*/
|
||||
private fun key(note: Note): String? = note.remindAt?.let { "${note.id}@$it" }
|
||||
}
|
||||
|
||||
/** Which reminder occurrences have already been put in front of someone. */
|
||||
private class Announced(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
/** False only before the very first [Reminders.refresh] on this install. */
|
||||
val primed: Boolean get() = prefs.getBoolean(KEY_PRIMED, false)
|
||||
|
||||
// Copied: the set from getStringSet must not be mutated, and the docs are
|
||||
// explicit that doing so corrupts what is stored.
|
||||
fun keys(): Set<String> = prefs.getStringSet(KEY_SEEN, emptySet())?.toSet().orEmpty()
|
||||
|
||||
fun write(keys: Set<String>) {
|
||||
prefs
|
||||
.edit()
|
||||
.putStringSet(KEY_SEEN, keys)
|
||||
.putBoolean(KEY_PRIMED, true)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Survives a restart, so the prompt is a one-off rather than once per launch. */
|
||||
val askedToNotify: Boolean get() = prefs.getBoolean(KEY_ASKED, false)
|
||||
|
||||
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
|
||||
|
||||
private companion object {
|
||||
const val FILE = "thoughtsync-reminders"
|
||||
const val KEY_SEEN = "announced"
|
||||
const val KEY_PRIMED = "primed"
|
||||
const val KEY_ASKED = "asked_to_notify"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* When the system should sync on its own.
|
||||
*
|
||||
* All of the policy lives here rather than being spread across the call sites,
|
||||
* because the interesting question is not "how do I enqueue work" but "how often
|
||||
* is often enough" — and that answer should be readable in one place.
|
||||
*
|
||||
* Two jobs, deliberately different:
|
||||
*
|
||||
* - **[enable]** is the heartbeat. Fifteen minutes is not a preference, it is
|
||||
* WorkManager's floor for periodic work; asking for less silently gets you
|
||||
* fifteen anyway. It keeps a phone that is sitting in a pocket roughly current
|
||||
* so that opening the app is not a wait.
|
||||
* - **[pushSoon]** is for the moment a person walks away from a note they just
|
||||
* wrote. Waiting up to fifteen minutes to hand that to the server is the
|
||||
* difference between "my notes are everywhere" and "my notes are on whichever
|
||||
* device I used last", which is the whole point of the product.
|
||||
*
|
||||
* Both require a network. Without that constraint every run on a phone with no
|
||||
* signal would wake the process, open SQLite, fail a connection and burn the
|
||||
* retry budget for nothing.
|
||||
*/
|
||||
object SyncSchedule {
|
||||
/** Every 15 minutes while linked. */
|
||||
fun enable(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
|
||||
.setConstraints(networkRequired())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
|
||||
// a changed interval forever on any device that had ever enqueued the old
|
||||
// one. UPDATE applies the change WITHOUT resetting the next run, so
|
||||
// opening the app repeatedly cannot push the sync further away each time.
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
|
||||
/** Stop syncing on our own: unlinked, or the person turned it off. */
|
||||
fun disable(context: Context) {
|
||||
WorkManager.getInstance(context).apply {
|
||||
cancelUniqueWork(PERIODIC)
|
||||
cancelUniqueWork(PUSH)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whatever is unsent off this device, as soon as there is a network.
|
||||
*
|
||||
* Enqueued when the app goes to the background holding unsent changes, so a
|
||||
* note survives being written on a phone that is then put away for the night.
|
||||
*/
|
||||
fun pushSoon(context: Context) {
|
||||
val request =
|
||||
OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setConstraints(networkRequired())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
|
||||
// backoff, the person has just given us a reason to try again sooner.
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
private fun networkRequired(): Constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
private const val PERIODIC = "thoughtsync-periodic-sync"
|
||||
private const val PUSH = "thoughtsync-push-pending"
|
||||
|
||||
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
|
||||
private const val PERIOD_MINUTES = 15L
|
||||
private const val BACKOFF_SECONDS = 30L
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Whether this device syncs on its own, and nothing else.
|
||||
*
|
||||
* Device-local on purpose. Every other piece of sync state — the server, the
|
||||
* token, the cursor — lives in the core's SQLite file because it describes the
|
||||
* PAIRING and has to survive a reinstall to the same account. This describes
|
||||
* how one phone behaves, and a person who turns it off on their handset is not
|
||||
* asking their laptop to stop.
|
||||
*
|
||||
* `SharedPreferences` rather than the store because the background worker reads
|
||||
* it on a process the system started, where reaching for the core would mean
|
||||
* depending on the store having opened successfully to answer a question that
|
||||
* has nothing to do with the store.
|
||||
*/
|
||||
class SyncSettings(
|
||||
context: Context,
|
||||
) {
|
||||
// applicationContext: this outlives any Activity, and holding one here would
|
||||
// leak the whole window when the phone rotates.
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Defaults to ON.
|
||||
*
|
||||
* Linking a server IS the consent — a person who paired this device and then
|
||||
* had to find a second switch before anything moved would reasonably call
|
||||
* that broken. Turning it off leaves manual sync working exactly as before.
|
||||
*/
|
||||
var automatic: Boolean
|
||||
get() = prefs.getBoolean(KEY_AUTOMATIC, true)
|
||||
set(value) {
|
||||
prefs.edit().putBoolean(KEY_AUTOMATIC, value).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE = "thoughtsync-sync"
|
||||
const val KEY_AUTOMATIC = "automatic"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
/**
|
||||
* One sync cycle, run by the system rather than by a person.
|
||||
*
|
||||
* WorkManager may start the process to run this, which means [ThoughtSyncApplication.onCreate]
|
||||
* has already opened the store by the time [doWork] is called — the same handle
|
||||
* the UI uses, so there is never a second SQLite connection racing the first.
|
||||
*
|
||||
* The automatic-sync switch is checked here as well as at scheduling time. That
|
||||
* does NOT avoid opening the store — `onCreate` has already done it by the time
|
||||
* any Worker runs — it avoids the network call and the writes.
|
||||
*
|
||||
* ## Why the outcome is thrown away
|
||||
*
|
||||
* A run that nobody asked for must not become a notification, a banner, or
|
||||
* anything else that interrupts. If it pulled changes, the board reloads next
|
||||
* time it is looked at; if it pushed them, they are gone from the outbox. The
|
||||
* one thing the person can act on — "there are unsent notes" — is already told
|
||||
* by the drawer badge, from `has_pending`, which does not care how the attempt
|
||||
* was made.
|
||||
*/
|
||||
class SyncWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val core = (applicationContext as? ThoughtSyncApplication)?.core
|
||||
|
||||
// Both of these are "nothing to do", not "something went wrong", so both
|
||||
// report success and let the run retire quietly:
|
||||
// * automatic sync was switched off after this was enqueued — the
|
||||
// schedule is cancelled then, but a run already handed to the system
|
||||
// can still land;
|
||||
// * the store never opened, which no retry fixes this launch and which
|
||||
// the UI is already reporting to whoever is looking.
|
||||
if (!SyncSettings(applicationContext).automatic || core == null) return Result.success()
|
||||
|
||||
return try {
|
||||
// Unlinked since this was enqueued. Not a failure — retrying would
|
||||
// burn the backoff schedule on a device that has no server.
|
||||
if (core.syncStatus().linked) {
|
||||
val outcome = core.syncNow()
|
||||
Log.i(TAG, "background sync at ${outcome.status.lastSyncAt}")
|
||||
// A pull can have brought in a reminder set on another device, or
|
||||
// moved one this phone already knew about. The alarm is derived
|
||||
// from the store, so it has to be re-derived whenever the store
|
||||
// changed underneath it — otherwise a reminder made at a desk
|
||||
// never rings on the phone until the app is next opened.
|
||||
Reminders.refresh(applicationContext, core)
|
||||
}
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
// Deliberately broad, and deliberately `retry` rather than `failure`:
|
||||
// almost everything that goes wrong here is a flat tyre — no route to
|
||||
// the server, a laptop asleep, a token being rotated. Retry hands it
|
||||
// to WorkManager's exponential backoff; `failure` would drop the run
|
||||
// for good and strand the notes until someone opens the app by hand.
|
||||
Log.w(TAG, "background sync failed, will retry", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ThoughtSyncWorker"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
|
||||
/**
|
||||
* Opens the shared Rust core once, for the process lifetime.
|
||||
*
|
||||
* The store is a single SQLite file behind a mutex, so one handle is both
|
||||
* sufficient and correct — a second would be two connections racing for the same
|
||||
* lock. This mirrors how the desktop manages it as Tauri app state.
|
||||
*
|
||||
* [filesDir] is app-private storage: readable by this app and nothing else,
|
||||
* removed on uninstall, and never on external media. The core does not guess at
|
||||
* platform paths; Android is the only thing that knows where this is.
|
||||
*/
|
||||
class ThoughtSyncApplication : Application() {
|
||||
/**
|
||||
* Null only if the store could not be opened — a corrupt or unwritable
|
||||
* database. The UI reports that honestly rather than crashing on first
|
||||
* touch, because a user whose notes won't open needs a message, not a
|
||||
* stack trace.
|
||||
*/
|
||||
var core: ThoughtSync? = null
|
||||
private set
|
||||
|
||||
var openFailure: String? = null
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
try {
|
||||
val handle = ThoughtSync(filesDir.absolutePath)
|
||||
core = handle
|
||||
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||
} catch (e: Exception) {
|
||||
// Deliberately broad: whatever went wrong, the app still has to
|
||||
// start and say so. Narrowing this would mean an unanticipated
|
||||
// failure mode takes the process down at launch instead.
|
||||
openFailure = e.message ?: e.toString()
|
||||
Log.e(TAG, "could not open the local store", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ThoughtSync"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* The last thing the system said about an install, waiting to be shown.
|
||||
*
|
||||
* A process-wide holder because the two ends cannot reach each other any other
|
||||
* way: [UpdateReceiver] is constructed by the system, and the view model that
|
||||
* wants the answer is owned by the composition. The alternative — a bound service
|
||||
* or a broadcast the UI also listens for — is more machinery for one nullable
|
||||
* string.
|
||||
*
|
||||
* Safe as snapshot state: `onReceive` runs on the main thread, which is where
|
||||
* Compose expects its state to be written.
|
||||
*/
|
||||
object UpdateOutcome {
|
||||
/** `error == null` means it went through, or the person declined. */
|
||||
data class Result(
|
||||
val error: String?,
|
||||
)
|
||||
|
||||
/** Null until the system has said something about an install we committed. */
|
||||
var latest: Result? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun report(error: String?) {
|
||||
latest = Result(error)
|
||||
}
|
||||
|
||||
/** Called once the UI has shown it, so a later install starts from silence. */
|
||||
fun clear() {
|
||||
latest = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* What the system says about an install we committed.
|
||||
*
|
||||
* Without this the app would `commit` and learn nothing — a failed install would
|
||||
* look exactly like a person deciding not to go ahead, and the update card would
|
||||
* sit there claiming an update is available with no explanation of why nothing
|
||||
* happened. That was the specific complaint recorded against Minstrel's first
|
||||
* attempt (Scribe #2438).
|
||||
*
|
||||
* The result is written to [UpdateOutcome] rather than notified: the app is on
|
||||
* screen when this fires — someone just tapped Update — so the place to say it is
|
||||
* the card they are looking at.
|
||||
*/
|
||||
class UpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (intent.action != ACTION_INSTALLED) return
|
||||
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE)
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
// Android wants a confirmation. This is the ORDINARY path below API
|
||||
// 31, and the path on 31+ whenever the OS declines to skip the
|
||||
// dialog — which it may, and is entitled to.
|
||||
val confirm =
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) {
|
||||
UpdateOutcome.report("Android asked for confirmation but sent no way to give it.")
|
||||
return
|
||||
}
|
||||
// NEW_TASK because a receiver has no activity of its own to start
|
||||
// from. The app is in the foreground, so this surfaces immediately.
|
||||
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(confirm) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not show the install confirmation", it)
|
||||
UpdateOutcome.report("Android's install confirmation could not be shown.")
|
||||
}
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
// Rarely seen: a successful self-update replaces this process, so
|
||||
// the app is usually gone before it can act on this.
|
||||
Log.i(TAG, "update installed")
|
||||
UpdateOutcome.report(null)
|
||||
}
|
||||
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED ->
|
||||
// Someone declined. Not an error, and saying "install failed" for a
|
||||
// deliberate choice is how an app sounds broken when it is not.
|
||||
UpdateOutcome.report(null)
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "install failed: status=$status message=$message")
|
||||
UpdateOutcome.report(message ?: "The update did not install.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
|
||||
private const val TAG = "ThoughtSyncUpdate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.pullToRefresh
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun BoardScreen(
|
||||
state: BoardState,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenNote: (Note) -> Unit,
|
||||
sync: BoardSync,
|
||||
onOpenSync: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
NavigationDrawer(
|
||||
current = state.destination,
|
||||
labels = state.labels,
|
||||
syncSummary = sync.summary,
|
||||
onOpen = {
|
||||
onOpen(it)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onOpenSync = {
|
||||
onOpenSync()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
// The + is the ONLY way in, by design: one obvious target rather
|
||||
// than a capture bar and a button competing for the same job.
|
||||
FloatingActionButton(
|
||||
onClick = onCompose,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.compose_open))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = onSearch,
|
||||
onMenu = { scope.launch { drawerState.open() } },
|
||||
)
|
||||
|
||||
state.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||
}
|
||||
// Stacked rather than one-or-the-other. A store failure and a sync
|
||||
// failure are different facts about different halves of the app;
|
||||
// hiding either behind the other would report the wrong problem.
|
||||
sync.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||
}
|
||||
|
||||
// Only where someone is already thinking about reminders. On the
|
||||
// main board it would nag people who have never set one.
|
||||
if (state.destination == Destination.Reminders) ReminderNotice()
|
||||
|
||||
val pull = rememberPullToRefreshState()
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pullToRefresh(
|
||||
isRefreshing = sync.refreshing,
|
||||
state = pull,
|
||||
// INERT on a device with no server, rather than
|
||||
// spinning and finding nothing: there is no remote
|
||||
// to fetch from, and a gesture that always comes
|
||||
// back empty teaches people it is broken.
|
||||
enabled = sync.canRefresh && !state.loading,
|
||||
onRefresh = sync.onRefresh,
|
||||
),
|
||||
) {
|
||||
when {
|
||||
state.loading -> LoadingBoard()
|
||||
state.notes.isEmpty() -> EmptyBoard(state)
|
||||
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
|
||||
}
|
||||
// `PullToRefreshBox` would be less code, but it takes no
|
||||
// `enabled`, so the modifier and the indicator are wired by
|
||||
// hand to keep the gate above.
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pull,
|
||||
isRefreshing = sync.refreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the board knows about sync, which is deliberately not much.
|
||||
*
|
||||
* A holder rather than six loose parameters, for the reason `EditorAction`
|
||||
* exists: [summary] and [error] are both `String?` and both about sync, so as
|
||||
* positional arguments they could be swapped with nothing to catch it. Named
|
||||
* fields make that unsayable.
|
||||
*
|
||||
* Passed in rather than read from [BoardState]. Sync has its own view model, and
|
||||
* giving the board a second copy of "is this device linked" would be two sources
|
||||
* of truth for one fact.
|
||||
*/
|
||||
data class BoardSync(
|
||||
/** One line for the drawer badge, or null when there is nothing worth saying. */
|
||||
val summary: String?,
|
||||
/** The last sync failure, still unacknowledged. */
|
||||
val error: String?,
|
||||
/** A sync is in flight — drives the indicator, whoever started it. */
|
||||
val refreshing: Boolean,
|
||||
/** Whether there is a server to refresh FROM. False means the gesture is off. */
|
||||
val canRefresh: Boolean,
|
||||
val onRefresh: () -> Unit,
|
||||
val onDismissError: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* A search field IS the top bar, following the phone convention rather than the
|
||||
* desktop's title-plus-sidebar.
|
||||
*
|
||||
* On a phone, finding a note you already wrote is the most common thing after
|
||||
* writing one, and burying it behind an icon costs a tap every time. The drawer
|
||||
* lives inside it on the left, which is where every Android user reaches for
|
||||
* navigation.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
|
||||
// the system-bar insets to its content padding, so adding them again put
|
||||
// the whole status bar's height of empty space above the search field —
|
||||
// roughly a centimetre of nothing at the top of the first screen anyone
|
||||
// sees. Insets get consumed once, by whichever component owns the edge.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GUTTER, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(SEARCH_RADIUS),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open))
|
||||
}
|
||||
PlainTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
hint = R.string.search_hint,
|
||||
singleLine = true,
|
||||
// The search key is decorative here: results already land as you
|
||||
// type, so pressing it should dismiss the keyboard and change
|
||||
// nothing, which is what an empty handler does.
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = {}),
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_clear))
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigationDrawer(
|
||||
current: Destination,
|
||||
labels: List<Label>,
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(start = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||
)
|
||||
|
||||
listOf(Destination.Notes, Destination.Reminders).forEach { destination ->
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.nav_labels),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
||||
)
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerRow(
|
||||
destination: Destination,
|
||||
current: Destination,
|
||||
onOpen: (Destination) -> Unit,
|
||||
) {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(destination.title) },
|
||||
selected = destination == current,
|
||||
onClick = { onOpen(destination) },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The board: a two-column masonry, matching the web and desktop.
|
||||
*
|
||||
* Staggered rather than a uniform grid because notes are wildly different heights
|
||||
* — a one-line thought beside a twelve-item checklist — and forcing them to a
|
||||
* common height either clips the long ones or strands whitespace under the short
|
||||
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
|
||||
*/
|
||||
@Composable
|
||||
private fun NoteBoard(
|
||||
notes: List<Note>,
|
||||
onOpenNote: (Note) -> Unit,
|
||||
) {
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Bottom padding clears the FAB, so the last note is never trapped under it.
|
||||
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
|
||||
verticalItemSpacing = 8.dp,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Keyed by id so Compose reuses cards across a refresh rather than
|
||||
// rebuilding them — and so a newly captured note slides in instead of
|
||||
// making every card below it flicker.
|
||||
items(items = notes, key = { it.id }) { note ->
|
||||
NoteCard(note = note, onOpen = { onOpenNote(note) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state, which has to say something DIFFERENT per destination.
|
||||
*
|
||||
* "Nothing here yet" is encouraging on an empty board and wrong in Trash, where it
|
||||
* should read as reassurance, and misleading after a search, where the notes exist
|
||||
* but did not match.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyBoard(state: BoardState) {
|
||||
val (title, body) =
|
||||
when {
|
||||
state.searching ->
|
||||
stringResource(R.string.empty_search_title) to
|
||||
stringResource(R.string.empty_search_body, state.query)
|
||||
state.destination == Destination.Trash ->
|
||||
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
|
||||
state.destination == Destination.Archive ->
|
||||
stringResource(R.string.empty_archive_title) to stringResource(R.string.empty_archive_body)
|
||||
state.destination == Destination.Reminders ->
|
||||
stringResource(R.string.empty_reminders_title) to stringResource(R.string.empty_reminders_body)
|
||||
else ->
|
||||
stringResource(R.string.board_empty_title) to stringResource(R.string.board_empty_body)
|
||||
}
|
||||
|
||||
// A LazyColumn holding one centred item, NOT a plain Column. Pull-to-refresh
|
||||
// works through nested scroll, and a layout that never scrolls never dispatches
|
||||
// any — so on a Column the gesture would be dead on exactly the screen where it
|
||||
// matters most: linked, board empty, notes still on the server.
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
item {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingBoard() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the store could not be opened at all.
|
||||
*
|
||||
* No retry: whatever stopped SQLite opening will stop it again this launch. Saying
|
||||
* so plainly beats a button that does nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun StoreUnavailableScreen(reason: String?) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.store_unavailable_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = reason ?: stringResource(R.string.store_unavailable_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val BOARD_COLUMNS = 2
|
||||
|
||||
// Not private: the reminder notice is board content and has to line up with the
|
||||
// search bar and the cards, so it shares the board's gutter rather than guessing.
|
||||
internal val GUTTER = 12.dp
|
||||
private val SEARCH_RADIUS = 28.dp
|
||||
@@ -0,0 +1,449 @@
|
||||
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.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteDraft
|
||||
import com.fabledsword.thoughtsync.core.NoteEdit
|
||||
import com.fabledsword.thoughtsync.core.NoteQuery
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Which pile of notes the board is showing. Mirrors the desktop sidebar.
|
||||
*
|
||||
* A sealed type rather than a string so the `when` that loads them is exhaustive —
|
||||
* adding a destination becomes a compile error at the loader instead of a silently
|
||||
* empty board.
|
||||
*/
|
||||
sealed interface Destination {
|
||||
val title: String
|
||||
|
||||
data object Notes : Destination {
|
||||
override val title = "Notes"
|
||||
}
|
||||
|
||||
data object Reminders : Destination {
|
||||
override val title = "Reminders"
|
||||
}
|
||||
|
||||
data object Archive : Destination {
|
||||
override val title = "Archive"
|
||||
}
|
||||
|
||||
data object Trash : Destination {
|
||||
override val title = "Trash"
|
||||
}
|
||||
|
||||
data class WithLabel(
|
||||
val id: String,
|
||||
override val title: String,
|
||||
) : Destination
|
||||
}
|
||||
|
||||
/** Everything the board renders from, in one immutable snapshot. */
|
||||
data class BoardState(
|
||||
val destination: Destination = Destination.Notes,
|
||||
val notes: List<Note> = emptyList(),
|
||||
val labels: List<Label> = emptyList(),
|
||||
val query: String = "",
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val error: String? = null,
|
||||
/**
|
||||
* The note the editor is open on, or null for the board.
|
||||
*
|
||||
* The NOTE and not its id, so the editor always renders from the same object
|
||||
* the store last returned. Every mutation hands back the reloaded note, so
|
||||
* ticking a box or picking a colour updates this in place and the editor never
|
||||
* has to re-query to see its own change.
|
||||
*/
|
||||
val editing: Note? = null,
|
||||
) {
|
||||
/** Search overrides the destination while there is a query to run. */
|
||||
val searching: Boolean get() = query.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the board off the shared Rust core.
|
||||
*
|
||||
* Every store call is a BLOCKING FFI call — synchronous SQLite behind a mutex — so
|
||||
* they run on [Dispatchers.IO]. Doing otherwise would block the main thread on
|
||||
* disk, which is the jank a native client exists to avoid.
|
||||
*
|
||||
* ONE view model for both screens, over detekt's objection. The obvious split —
|
||||
* a second one for the editor — fails on the fact that every editor mutation has
|
||||
* to reload the board behind it, so the editor's view model would need a
|
||||
* reference back into this one and the two would share the note list anyway. What
|
||||
* is left is a dozen small functions around a single coherent state machine,
|
||||
* which is what the suppression says rather than hides.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class BoardViewModel(
|
||||
private val core: ThoughtSync,
|
||||
/**
|
||||
* Called after any write that could have moved a reminder.
|
||||
*
|
||||
* The alarm is derived from the store, so anything that edits the store can
|
||||
* invalidate it — setting a time, completing one, trashing the note it is on.
|
||||
* Wired as a callback rather than reaching for a Context from a view model,
|
||||
* which is how view models come to leak Activities. Same shape as the sync
|
||||
* view model's `onStoreChanged`.
|
||||
*/
|
||||
private val onRemindersChanged: () -> Unit = {},
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(BoardState())
|
||||
private set
|
||||
|
||||
/**
|
||||
* The in-flight search. Held so each keystroke cancels the previous one:
|
||||
* without it, a fast typist queues one full-text query per character and the
|
||||
* results arrive out of order, so the board can settle on a stale answer.
|
||||
*/
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
loadLabels()
|
||||
}
|
||||
|
||||
fun open(destination: Destination) {
|
||||
// Clearing the query is deliberate: picking Archive while a search is
|
||||
// running should show the archive, not search results filtered by a box
|
||||
// the user has visually moved on from.
|
||||
searchJob?.cancel()
|
||||
state = state.copy(destination = destination, query = "")
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(loading = true)
|
||||
state =
|
||||
try {
|
||||
val notes = withContext(Dispatchers.IO) { load(state.destination) }
|
||||
state.copy(notes = notes, loading = false, error = null)
|
||||
} catch (e: Exception) {
|
||||
// Broad by intent: the board must render something for any
|
||||
// failure, and the core reports problems as one error type
|
||||
// carrying a message meant to be shown.
|
||||
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(destination: Destination): List<Note> =
|
||||
when (destination) {
|
||||
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
|
||||
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
|
||||
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
||||
// Not a board view: the core models reminders as its own query, since
|
||||
// "has a reminder" cuts across archived and active alike.
|
||||
Destination.Reminders -> core.reminderNotes()
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||
}
|
||||
|
||||
private fun loadLabels() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.onSuccess { state = state.copy(labels = it) }
|
||||
// A drawer that cannot list labels is a degraded drawer, not a
|
||||
// broken board — the notes are still there. Failing quietly here
|
||||
// beats an error banner over working content.
|
||||
.onFailure { state = state.copy(labels = emptyList()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(text: String) {
|
||||
state = state.copy(query = text)
|
||||
searchJob?.cancel()
|
||||
|
||||
if (text.isBlank()) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
|
||||
searchJob =
|
||||
viewModelScope.launch {
|
||||
// Let the typing settle before hitting the store. Short enough to
|
||||
// feel live, long enough that a whole word is one query.
|
||||
delay(SEARCH_DEBOUNCE_MS)
|
||||
state = state.copy(loading = true)
|
||||
state =
|
||||
try {
|
||||
val hits = withContext(Dispatchers.IO) { core.searchNotes(text) }
|
||||
state.copy(notes = hits, loading = false, error = null)
|
||||
} catch (e: Exception) {
|
||||
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new note or list.
|
||||
*
|
||||
* Blank input is ignored rather than rejected: an empty save is a slip, not a
|
||||
* mistake worth interrupting someone over.
|
||||
*/
|
||||
fun create(content: String) {
|
||||
val cleanContent = content.trim()
|
||||
if (cleanContent.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
|
||||
// Prepend rather than reload: the new note belongs at the top
|
||||
// of the board, and a full re-query would cost a round trip to
|
||||
// tell us what we already know. Skipped when the board is not
|
||||
// showing plain notes — a note created while looking at Trash
|
||||
// does not belong in that list.
|
||||
val notes =
|
||||
if (state.destination == Destination.Notes && !state.searching) {
|
||||
listOf(created) + state.notes
|
||||
} else {
|
||||
state.notes
|
||||
}
|
||||
// A capture sheet can carry a reminder in its text one day;
|
||||
// more to the point, this is a store write and the rule here is
|
||||
// that every store write re-derives the alarm rather than each
|
||||
// call site deciding whether its particular write could matter.
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
state.copy(notes = notes, saving = false, error = null)
|
||||
} catch (e: Exception) {
|
||||
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── the editor ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a note by id, for a notification tap.
|
||||
*
|
||||
* Loads it fresh rather than searching the board's list: the board may be
|
||||
* showing Trash, a label, or search results, and a reminder can fire for a note
|
||||
* that is in none of them.
|
||||
*/
|
||||
fun openNoteById(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
|
||||
.onSuccess { state = state.copy(editing = it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openNote(note: Note) {
|
||||
state = state.copy(editing = note)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one editor action to the note the editor is open on.
|
||||
*
|
||||
* The `when` is exhaustive by construction, so adding a variant to
|
||||
* [EditorAction] breaks THIS function until it is handled — which is the whole
|
||||
* reason the editor speaks in actions rather than through a bundle of
|
||||
* callbacks. The note is passed in rather than read from `state.editing` so a
|
||||
* mutation that lands between a tap and its dispatch cannot redirect the
|
||||
* action at a different note.
|
||||
*
|
||||
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
|
||||
* total function over it is twenty branches and sixty-odd lines no matter how
|
||||
* it is written. Splitting it into sub-dispatchers is the only way to shorten
|
||||
* it, and each of those would need an `else` — which throws away precisely the
|
||||
* exhaustiveness this shape exists for. Suppressed rather than worked around,
|
||||
* because the rules are measuring the action type's size, not this function's.
|
||||
*/
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
fun onEditorAction(
|
||||
note: Note,
|
||||
action: EditorAction,
|
||||
) {
|
||||
val id = note.id
|
||||
when (action) {
|
||||
EditorAction.Close -> state = state.copy(editing = null)
|
||||
EditorAction.DismissError -> dismissError()
|
||||
|
||||
// Saved on close rather than per keystroke, so a session of typing
|
||||
// costs one write and one revision snapshot.
|
||||
is EditorAction.SaveText ->
|
||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||
|
||||
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
||||
|
||||
// Pinning re-sorts the board rather than emptying it, and on a phone
|
||||
// you often pin while still reading — so unlike the three below, it
|
||||
// deliberately leaves the editor open.
|
||||
is EditorAction.SetPinned -> edit(id, NoteEdit.Pinned(action.pinned))
|
||||
|
||||
// Archiving, trashing and restoring all take the note out of the list
|
||||
// you were looking at, so the editor closes behind them: staying open
|
||||
// on a note that has visibly left the board reads as a bug.
|
||||
is EditorAction.SetArchived ->
|
||||
mutate(closeEditor = true) {
|
||||
it.updateNote(id, listOf(NoteEdit.Archived(action.archived)))
|
||||
}
|
||||
EditorAction.Trash -> mutate(closeEditor = true) { it.trashNote(id) }
|
||||
EditorAction.Restore -> mutate(closeEditor = true) { it.restoreNote(id) }
|
||||
EditorAction.DeleteForever ->
|
||||
mutate(closeEditor = true) {
|
||||
it.deleteNoteForever(id)
|
||||
// Nothing to hand back — the row is gone. The board reload
|
||||
// inside `mutate` is what makes it disappear.
|
||||
null
|
||||
}
|
||||
|
||||
// An empty first item: the checklist editor appears the moment the note
|
||||
// has one, and an empty row is what someone can type straight into.
|
||||
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
|
||||
|
||||
is EditorAction.AddItem ->
|
||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
||||
mutate { it.addItem(id, text) }
|
||||
}
|
||||
is EditorAction.SetItemChecked ->
|
||||
mutate { it.setItemChecked(id, action.itemId, action.checked) }
|
||||
is EditorAction.SetItemText ->
|
||||
mutate { it.setItemText(id, action.itemId, action.text) }
|
||||
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
|
||||
|
||||
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
|
||||
|
||||
is EditorAction.CreateLabel ->
|
||||
action.name.trim().takeIf { it.isNotEmpty() }?.let { name ->
|
||||
mutate {
|
||||
val label = it.createLabel(name)
|
||||
val manual = note.labels.filterNot { l -> l.viaTag }.map { l -> l.id }
|
||||
it.setNoteLabels(id, (manual + label.id).distinct())
|
||||
}
|
||||
// The drawer lists labels with their note counts, and both
|
||||
// just changed.
|
||||
loadLabels()
|
||||
}
|
||||
|
||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||
EditorAction.ClearReminder -> edit(id, NoteEdit.ClearRemindAt)
|
||||
EditorAction.CompleteReminder -> mutate { it.completeReminder(id) }
|
||||
is EditorAction.SnoozeReminder -> mutate { it.snoozeReminder(id, action.minutes) }
|
||||
is EditorAction.SetRecurrence ->
|
||||
edit(
|
||||
id,
|
||||
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The common case: one field-level edit to one note. */
|
||||
private fun edit(
|
||||
id: String,
|
||||
change: NoteEdit,
|
||||
) = mutate { it.updateNote(id, listOf(change)) }
|
||||
|
||||
/**
|
||||
* The one path every store mutation takes.
|
||||
*
|
||||
* Each core mutation returns the reloaded note, which goes straight into
|
||||
* [BoardState.editing] so an open editor shows its own change without a
|
||||
* re-query. The BOARD list is then reloaded rather than patched in place:
|
||||
* pinning re-sorts it, archiving removes the note from it, and adding a label
|
||||
* can move it in or out of a label view — a splice would have to reimplement
|
||||
* the core's ordering and membership rules in Kotlin to get any of that right.
|
||||
* The reload is a local SQLite query, so it costs less than the code that would
|
||||
* avoid it.
|
||||
*
|
||||
* Quiet, deliberately: no spinner, because the board is already on screen with
|
||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||
* lie than showing it one frame stale.
|
||||
*
|
||||
* Search results are left alone — they are the answer to a query, not a live
|
||||
* view, and re-running the board query underneath them would replace the hits
|
||||
* with the whole board.
|
||||
*/
|
||||
private fun mutate(
|
||||
closeEditor: Boolean = false,
|
||||
block: (ThoughtSync) -> Note?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||
val notes =
|
||||
if (state.searching) {
|
||||
state.notes
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { load(state.destination) }
|
||||
}
|
||||
// On IO, not here: re-deriving the alarm reads every note
|
||||
// that carries a reminder, and this line runs on the main
|
||||
// thread — the coroutine is back from its withContext by now.
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
state.copy(
|
||||
notes = notes,
|
||||
editing = if (closeEditor) null else updated ?: state.editing,
|
||||
saving = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Broad by intent, as elsewhere: the core reports every failure
|
||||
// as one error type carrying a message meant to be shown, and a
|
||||
// half-applied edit must still leave a usable screen.
|
||||
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FALLBACK_ERROR = "Something went wrong."
|
||||
private const val SEARCH_DEBOUNCE_MS = 180L
|
||||
|
||||
// The core's board vocabulary. "archived", not "archive" — it matches on
|
||||
// the former and silently falls through to the default board otherwise.
|
||||
private const val VIEW_NOTES = "notes"
|
||||
private const val VIEW_ARCHIVE = "archived"
|
||||
private const val VIEW_TRASH = "trash"
|
||||
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
onRemindersChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
BoardViewModel(core, onRemindersChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The palette key a note starts on, matching the web and the desktop. */
|
||||
private const val DEFAULT_COLOR = "default"
|
||||
|
||||
// ── pure builders ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Neither of these reads or writes view-model state; they only shape a core input
|
||||
// from arguments. Kept at file scope so the class above holds only things that
|
||||
// actually depend on it — which is also what keeps its function count meaningful.
|
||||
|
||||
private fun query(
|
||||
view: String,
|
||||
labelId: String? = null,
|
||||
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
||||
|
||||
private fun draft(content: String): NoteDraft =
|
||||
// The core names the note from the body's first line, so a captured thought is
|
||||
// findable without anyone being asked to name it. A checklist is added afterwards,
|
||||
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
|
||||
@@ -0,0 +1,130 @@
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
|
||||
/**
|
||||
* The new-note surface, opened by the + button.
|
||||
*
|
||||
* A bottom sheet rather than a full screen: capture should feel like a quick aside
|
||||
* from the board, not a place you navigate to and have to come back from. The
|
||||
* board stays visible behind it, so the note lands somewhere you can already see.
|
||||
*
|
||||
* It asks note-or-list up front rather than making that a mode you discover later,
|
||||
* because on a phone the two are genuinely different typing tasks and switching
|
||||
* halfway is worse than choosing at the start.
|
||||
*
|
||||
* ## Leaving keeps what you wrote
|
||||
*
|
||||
* Every way out of this sheet except Discard SAVES: the save button, tapping the
|
||||
* board behind it, swiping down, back, and the app being backgrounded. A sheet
|
||||
* that throws away a typed thought because you touched outside it is a sheet that
|
||||
* teaches people not to trust the app with a thought — and capture is the one
|
||||
* place this product cannot afford that.
|
||||
*
|
||||
* The same shape the editor settled on, for the same reason, with one difference:
|
||||
* capture also has to be abandonable, because tapping + and changing your mind is
|
||||
* a normal thing to do. That is what Discard is, and it is the only path that
|
||||
* loses anything. An empty draft needs neither — it is simply dropped, since a
|
||||
* blank note nobody asked for is worse than no note at all.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ComposeSheet(
|
||||
saving: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
// Saveable, not just remembered: a rotation mid-sentence is the same lost
|
||||
// thought as a discarded one, and it was losing it before this.
|
||||
var content by rememberSaveable { mutableStateOf("") }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
|
||||
val written = content.isNotBlank()
|
||||
val leave = { if (written) onSave(content) else onDismiss() }
|
||||
|
||||
// Straight into the one field there is. A capture is a thought, and every field
|
||||
// someone has to tab past is the difference between "under a second" and not —
|
||||
// which is why the title field is gone rather than merely skipped (M13 step 3).
|
||||
LaunchedEffect(Unit) { contentFocus.requestFocus() }
|
||||
|
||||
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
|
||||
// + and then got distracted should find the composer where they left it; the
|
||||
// only reason to act here is that there is something to lose.
|
||||
FlushOnStop { if (written) onSave(content) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// No note/list switch any more: there is one thing to capture. A
|
||||
// checklist is added to a note in the editor, once there is a note.
|
||||
PlainTextField(
|
||||
value = content,
|
||||
onValueChange = { content = it },
|
||||
modifier = Modifier.focusRequester(contentFocus),
|
||||
hint = R.string.compose_body_hint,
|
||||
minLines = MIN_CONTENT_LINES,
|
||||
)
|
||||
|
||||
SheetActions(
|
||||
canSave = !saving && written,
|
||||
onDiscard = onDismiss,
|
||||
onSave = { onSave(content) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetActions(
|
||||
canSave: Boolean,
|
||||
onDiscard: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
// "Discard", not "Cancel". Cancel means "undo what I am doing", which is
|
||||
// precisely what leaving no longer does — the word would now describe the
|
||||
// one button it is NOT attached to.
|
||||
TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) }
|
||||
Button(onClick = onSave, enabled = canSave) {
|
||||
Text(stringResource(R.string.compose_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_CONTENT_LINES = 4
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
/**
|
||||
* Everything the editor can ask for, as one type.
|
||||
*
|
||||
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty
|
||||
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to
|
||||
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a
|
||||
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant
|
||||
* here breaks the dispatcher until it is handled, which is precisely the guarantee
|
||||
* the callback bundle could not offer.
|
||||
*
|
||||
* No variant carries a note id. The editor is open on exactly one note and the
|
||||
* dispatcher already has it, so threading it through every action would only
|
||||
* create the possibility of the two disagreeing.
|
||||
*/
|
||||
sealed interface EditorAction {
|
||||
/** Leave the editor. Text is saved separately, via [SaveText], before this. */
|
||||
data object Close : EditorAction
|
||||
|
||||
/** Clear the error banner. Shared state — the board shows the same one. */
|
||||
data object DismissError : EditorAction
|
||||
|
||||
data class SaveText(
|
||||
val body: String,
|
||||
) : EditorAction
|
||||
|
||||
data class SetColor(
|
||||
val color: String,
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* Give this note a checklist.
|
||||
*
|
||||
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
|
||||
* so nothing moves and nothing is swapped: the body stays exactly where it is and
|
||||
* the note gains a first, empty item for someone to type into.
|
||||
*/
|
||||
data object AddChecklist : EditorAction
|
||||
|
||||
data class SetPinned(
|
||||
val pinned: Boolean,
|
||||
) : EditorAction
|
||||
|
||||
data class SetArchived(
|
||||
val archived: Boolean,
|
||||
) : EditorAction
|
||||
|
||||
data object Trash : EditorAction
|
||||
|
||||
data object Restore : EditorAction
|
||||
|
||||
data object DeleteForever : EditorAction
|
||||
|
||||
data class AddItem(
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemChecked(
|
||||
val itemId: String,
|
||||
val checked: Boolean,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemText(
|
||||
val itemId: String,
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class DeleteItem(
|
||||
val itemId: String,
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* The note's MANUAL labels, replacing whatever was there.
|
||||
*
|
||||
* `#tag` labels must never appear in this list. They are owned by the body
|
||||
* text and the core re-derives them on every body edit — see
|
||||
* `set_note_labels` in the FFI crate.
|
||||
*/
|
||||
data class SetLabels(
|
||||
val labelIds: List<String>,
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* Create a label and attach it to this note in one gesture.
|
||||
*
|
||||
* Typing a new label in the picker and then having to tick it as well would
|
||||
* be two steps for one intention. The core finds-or-creates, so typing the
|
||||
* name of a label that already exists simply attaches that one.
|
||||
*/
|
||||
data class CreateLabel(
|
||||
val name: String,
|
||||
) : EditorAction
|
||||
|
||||
/** `at` is an RFC3339 instant — see `Time.kt` for why the UI writes it. */
|
||||
data class SetReminder(
|
||||
val at: String,
|
||||
) : EditorAction
|
||||
|
||||
data object ClearReminder : EditorAction
|
||||
|
||||
/**
|
||||
* Mark the reminder dealt with.
|
||||
*
|
||||
* Distinct from [ClearReminder] even though the core does the same thing to
|
||||
* the column today: this is where recurrence advancement lands when it is
|
||||
* built, so a recurring reminder finished through the generic clear would
|
||||
* silently stop recurring.
|
||||
*/
|
||||
data object CompleteReminder : EditorAction
|
||||
|
||||
data class SnoozeReminder(
|
||||
val minutes: Long,
|
||||
) : EditorAction
|
||||
|
||||
/** null is "does not repeat". */
|
||||
data class SetRecurrence(
|
||||
val rule: String?,
|
||||
) : EditorAction
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The checklist, with real checkboxes this time.
|
||||
*
|
||||
* The card renders glyphs because it is a preview; here every row is live. This is
|
||||
* the other half of the answer to how a list gets typed on a phone: the capture
|
||||
* sheet takes a whole list at once, one item per line, because at capture time the
|
||||
* list is already in your head and a tap per row would be the slow part. The
|
||||
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
|
||||
* where the per-row control lives.
|
||||
*
|
||||
* No empty state: a checklist with no items already shows the add row with its
|
||||
* hint, which says the same thing an empty state would and can be typed into.
|
||||
*/
|
||||
@Composable
|
||||
fun ChecklistEditor(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
note.items.forEach { item ->
|
||||
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
if (!readOnly) {
|
||||
AddItemRow(onAdd = { onAction(EditorAction.AddItem(it)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row: a live checkbox, editable text, and a remove button.
|
||||
*
|
||||
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
|
||||
* store write that reloads the note, so per-keystroke saving would both hammer
|
||||
* SQLite and race the reload against the next character.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChecklistRow(
|
||||
item: ChecklistItem,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
|
||||
// text being typed here.
|
||||
var text by remember(item.id) { mutableStateOf(item.text) }
|
||||
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
|
||||
enabled = !readOnly,
|
||||
)
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { if (!it.isFocused) commit() },
|
||||
enabled = !readOnly,
|
||||
singleLine = true,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
// Struck through when done, matching the card and the web.
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { commit() }),
|
||||
)
|
||||
if (!readOnly) {
|
||||
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.editor_remove_item),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-present row at the bottom for adding an item.
|
||||
*
|
||||
* It clears but keeps focus after a submit, so a list can be typed straight
|
||||
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
|
||||
* is the same speed the capture sheet's one-item-per-line field buys, carried into
|
||||
* the editor so refining a list never feels slower than making one.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddItemRow(onAdd: (String) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
hint = R.string.editor_add_item,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(onDone = {
|
||||
onAdd(text)
|
||||
text = ""
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The editor's action bar, at the bottom where a thumb already is.
|
||||
*
|
||||
* The three affordances with a permanent slot are the ones reached for while still
|
||||
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
||||
* archive, delete) is one tap further into the overflow, where it is spelled out
|
||||
* in WORDS.
|
||||
*
|
||||
* That split is a deliberate trade against icon-guessing. `material-icons-core`
|
||||
* carries no pin, archive or label glyph, and the two ways out were pulling in the
|
||||
* ~1,000-vector extended set for four icons, or pressing unrelated ones into
|
||||
* service — a star meaning "pin" is a star meaning "favourite" to everyone who has
|
||||
* used another app. Text says exactly what it does and reads correctly aloud.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditorBottomBar(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
tint: NoteTint,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
BottomAppBar(containerColor = tint.background(dark)) {
|
||||
if (!readOnly) {
|
||||
// A dot in the note's CURRENT colour rather than a palette icon: it
|
||||
// shows what the colour is as well as what the button does.
|
||||
IconButton(onClick = { onPicker(Picker.COLOR) }) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH_DOT)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.border(dark), CircleShape),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
// Adds the first checklist item, which is what makes the checklist
|
||||
// editor appear. Hidden once the note already has one — there is nothing
|
||||
// left to add that the checklist's own "+" row doesn't do better.
|
||||
if (note.items.isEmpty()) {
|
||||
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverflowMenu(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val close = { open = false }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = close) {
|
||||
if (readOnly) {
|
||||
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
||||
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
||||
} else {
|
||||
MenuItem(
|
||||
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||
close,
|
||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||
MenuItem(
|
||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||
close,
|
||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuItem(
|
||||
@StringRes labelRes: Int,
|
||||
onClose: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(labelRes)) },
|
||||
onClick = {
|
||||
// Close BEFORE acting. An overflow menu left hanging over the sheet
|
||||
// that just opened underneath it is the classic version of this bug,
|
||||
// and doing it here means no call site can forget.
|
||||
onClose()
|
||||
onClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's labels, each removable.
|
||||
*
|
||||
* `#tag` labels get no remove button: they are owned by the body text and the core
|
||||
* re-derives them on the next edit, so a cross that undid itself a second later
|
||||
* would look broken. The way to remove one is to delete the tag from the text,
|
||||
* which is what the trailing note says.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorLabelRow(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
note.labels.forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label.name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tint.chipForeground(dark),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
if (label.viaTag) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_from_tag),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
} else if (!readOnly) {
|
||||
IconButton(onClick = {
|
||||
// Only the MANUAL labels are sent: the core replaces
|
||||
// exactly those, and including a tag label here would ask
|
||||
// it to own something the body text already owns.
|
||||
val kept =
|
||||
note.labels
|
||||
.filterNot { it.viaTag || it.id == label.id }
|
||||
.map { it.id }
|
||||
onAction(EditorAction.SetLabels(kept))
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.editor_remove_label),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The set reminder, with the one-tap actions beside it.
|
||||
*
|
||||
* Done / 1h / 1d are the same three the web editor offers, for the same reason:
|
||||
* when a reminder surfaces, the answer is almost always "handled" or "not yet",
|
||||
* and making either of those cost a trip through the date picker is how a reminder
|
||||
* ends up ignored instead of dealt with.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorReminderRow(
|
||||
at: String,
|
||||
recurrence: String?,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = reminderLabel(at, recurrence),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color =
|
||||
if (isPast(at)) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (!readOnly) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = { onAction(EditorAction.CompleteReminder) }) {
|
||||
Text(stringResource(R.string.reminder_done))
|
||||
}
|
||||
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_HOUR)) }) {
|
||||
Text(stringResource(R.string.reminder_snooze_hour))
|
||||
}
|
||||
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_DAY)) }) {
|
||||
Text(stringResource(R.string.reminder_snooze_day))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SWATCH_DOT = 22.dp
|
||||
private const val SNOOZE_HOUR = 60L
|
||||
private const val SNOOZE_DAY = 1440L
|
||||
@@ -0,0 +1,468 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
// The three things you pick rather than type: a colour, a set of labels, a time.
|
||||
//
|
||||
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
|
||||
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
|
||||
// the note still visible above it — which matters when the choice you are making
|
||||
// is about the thing you are looking at.
|
||||
|
||||
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ColorSheet(
|
||||
selected: String,
|
||||
onPick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
SheetTitle(R.string.color_picker_title)
|
||||
// Chunked into fixed rows rather than a flow layout: ten swatches
|
||||
// always lay out as two rows of five on every phone width, and a flow
|
||||
// would reshuffle them between devices for no gain.
|
||||
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
row.forEach { (key, tint) ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH_SIZE)
|
||||
.clip(CircleShape)
|
||||
.background(tint.background(dark))
|
||||
.border(
|
||||
// The selected swatch gets a heavier ring
|
||||
// as well as a tick: on the pale tints the
|
||||
// tick alone is nearly invisible.
|
||||
if (key == selected) 2.dp else 1.dp,
|
||||
if (key == selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
tint.border(dark)
|
||||
},
|
||||
CircleShape,
|
||||
).clickable(onClickLabel = tint.label) { onPick(key) },
|
||||
) {
|
||||
if (key == selected) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = tint.label,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pad a short final row so its swatches line up with the row
|
||||
// above instead of spreading across the full width.
|
||||
repeat(SWATCHES_PER_ROW - row.size) {
|
||||
Box(modifier = Modifier.size(SWATCH_SIZE))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label, ticked where it is on the note.
|
||||
*
|
||||
* `#tag` labels appear ticked and disabled — they are true of the note, and they
|
||||
* are owned by its text, so showing them unticked would be a lie and letting them
|
||||
* be unticked would be a control that undoes itself.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelSheet(
|
||||
note: Note,
|
||||
labels: List<Label>,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var typed by remember { mutableStateOf("") }
|
||||
val manual =
|
||||
note.labels
|
||||
.filterNot { it.viaTag }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
val viaTag =
|
||||
note.labels
|
||||
.filter { it.viaTag }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
SheetTitle(R.string.label_picker_title)
|
||||
|
||||
PlainTextField(
|
||||
value = typed,
|
||||
onValueChange = { typed = it },
|
||||
hint = R.string.label_new_hint,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(onDone = {
|
||||
onAction(EditorAction.CreateLabel(typed))
|
||||
typed = ""
|
||||
}),
|
||||
)
|
||||
|
||||
// Capped rather than unbounded: a sheet that grows past the screen
|
||||
// makes its own scroll fight the sheet's drag gesture.
|
||||
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
|
||||
items(items = labels, key = { it.id }) { label ->
|
||||
val fromTag = label.id in viaTag
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = fromTag || label.id in manual,
|
||||
enabled = !fromTag,
|
||||
onCheckedChange = { on ->
|
||||
val next = if (on) manual + label.id else manual - label.id
|
||||
onAction(EditorAction.SetLabels(next.toList()))
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = label.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
if (fromTag) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_from_tag),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_none_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When to be reminded.
|
||||
*
|
||||
* Presets first, and a full picker behind them. On a phone almost every reminder
|
||||
* is "this evening", "tomorrow morning" or "next week" — the web's raw
|
||||
* `datetime-local` field is the right control for a desktop and three taps too
|
||||
* many for the common case here. The exact picker is still there, one tap down,
|
||||
* because "Thursday at 3" is a real thing to want.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReminderSheet(
|
||||
note: Note,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var exact by remember { mutableStateOf(false) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
SheetTitle(R.string.reminder_title)
|
||||
|
||||
reminderPresets().forEach { (labelRes, at) ->
|
||||
Text(
|
||||
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onAction(EditorAction.SetReminder(rfc3339(at)))
|
||||
onDismiss()
|
||||
}.padding(vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_pick),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { exact = true }
|
||||
.padding(vertical = 12.dp),
|
||||
)
|
||||
|
||||
// Repeat only appears once there IS a reminder — a recurrence rule on
|
||||
// a note with no time to recur from is a setting that does nothing.
|
||||
if (note.remindAt != null) {
|
||||
RecurrenceChips(
|
||||
current = note.recurrence,
|
||||
onPick = { onAction(EditorAction.SetRecurrence(it)) },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_clear),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onAction(EditorAction.ClearReminder)
|
||||
onDismiss()
|
||||
}.padding(vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exact) {
|
||||
ExactReminderPicker(
|
||||
initial = note.remindAt?.let { localTime(it) } ?: defaultPickerTime(),
|
||||
onPick = {
|
||||
onAction(EditorAction.SetReminder(rfc3339(it)))
|
||||
exact = false
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { exact = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Date then time, as two dialogs.
|
||||
*
|
||||
* Material 3 ships a date picker and a time picker but nothing that does both, and
|
||||
* a phone screen has no room for them side by side. Sequential also matches how
|
||||
* the choice is actually made — you know the day before you know the hour.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ExactReminderPicker(
|
||||
initial: LocalDateTime,
|
||||
onPick: (LocalDateTime) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var date by remember { mutableStateOf<LocalDate?>(null) }
|
||||
|
||||
if (date == null) {
|
||||
val state =
|
||||
rememberDatePickerState(
|
||||
initialSelectedDateMillis =
|
||||
initial
|
||||
.toLocalDate()
|
||||
.atStartOfDay(ZoneId.of("UTC"))
|
||||
.toInstant()
|
||||
.toEpochMilli(),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
// Nothing selected means nothing to confirm — the picker opens
|
||||
// on a date, so this only guards a user who cleared it.
|
||||
enabled = state.selectedDateMillis != null,
|
||||
onClick = {
|
||||
// The picker reports UTC midnight of the CALENDAR day that
|
||||
// was tapped, so it has to be read back in UTC. Reading it
|
||||
// in the device's zone shifts the date by one west of
|
||||
// Greenwich — the classic off-by-a-day in this control.
|
||||
date =
|
||||
state.selectedDateMillis?.let {
|
||||
Instant.ofEpochMilli(it).atZone(ZoneId.of("UTC")).toLocalDate()
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.picker_next)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
}
|
||||
} else {
|
||||
val state =
|
||||
rememberTimePickerState(
|
||||
initialHour = initial.hour,
|
||||
initialMinute = initial.minute,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.picker_time_title)) },
|
||||
text = { TimePicker(state = state) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onPick(
|
||||
LocalDateTime.of(
|
||||
requireNotNull(date) { "the time step is only reachable with a date" },
|
||||
LocalTime.of(state.hour, state.minute),
|
||||
),
|
||||
)
|
||||
}) { Text(stringResource(R.string.picker_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecurrenceChips(
|
||||
current: String?,
|
||||
onPick: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) {
|
||||
RECURRENCE_RULES.forEach { (rule, labelRes) ->
|
||||
FilterChip(
|
||||
selected = current.orEmpty() == rule.orEmpty(),
|
||||
onClick = { onPick(rule) },
|
||||
label = { Text(stringResource(labelRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetTitle(labelRes: Int) {
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The presets, computed against the device clock at the moment the sheet opens.
|
||||
*
|
||||
* "Later today" disappears once the evening has passed rather than silently
|
||||
* meaning tomorrow — an offer that quietly does something else is worse than one
|
||||
* that isn't there.
|
||||
*/
|
||||
private fun reminderPresets(): List<Pair<Int, LocalDateTime>> {
|
||||
val now = LocalDateTime.now()
|
||||
val presets = mutableListOf<Pair<Int, LocalDateTime>>()
|
||||
val evening = now.toLocalDate().atTime(EVENING_HOUR, 0)
|
||||
if (evening.isAfter(now)) {
|
||||
presets += R.string.reminder_later_today to evening
|
||||
}
|
||||
presets += R.string.reminder_tomorrow to now.toLocalDate().plusDays(1).atTime(MORNING_HOUR, 0)
|
||||
presets +=
|
||||
R.string.reminder_next_week to
|
||||
now
|
||||
.toLocalDate()
|
||||
.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||
.atTime(MORNING_HOUR, 0)
|
||||
return presets
|
||||
}
|
||||
|
||||
/** Where the exact picker opens when the note has no reminder yet. */
|
||||
private fun defaultPickerTime(): LocalDateTime =
|
||||
LocalDateTime
|
||||
.now()
|
||||
.toLocalDate()
|
||||
.plusDays(1)
|
||||
.atTime(MORNING_HOUR, 0)
|
||||
|
||||
/** The core's recurrence vocabulary; null is "does not repeat". */
|
||||
private val RECURRENCE_RULES: List<Pair<String?, Int>> =
|
||||
listOf(
|
||||
null to R.string.recurrence_none,
|
||||
"daily" to R.string.recurrence_daily,
|
||||
"weekly" to R.string.recurrence_weekly,
|
||||
"monthly" to R.string.recurrence_monthly,
|
||||
"yearly" to R.string.recurrence_yearly,
|
||||
)
|
||||
|
||||
private const val SWATCHES_PER_ROW = 5
|
||||
private const val EVENING_HOUR = 18
|
||||
private const val MORNING_HOUR = 8
|
||||
private val SWATCH_SIZE = 44.dp
|
||||
private val LABEL_LIST_MAX_HEIGHT = 320.dp
|
||||
@@ -0,0 +1,54 @@
|
||||
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.Row
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
|
||||
// Shared by the board and the editor.
|
||||
//
|
||||
// A failed save is most likely to happen WHILE the editor is open — that is where
|
||||
// the writes are — so a banner only the board could render meant the one screen
|
||||
// that needed it was the one screen without it.
|
||||
|
||||
@Composable
|
||||
fun ErrorBanner(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint("red")
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(BANNER_RADIUS))
|
||||
.background(tint.background(dark))
|
||||
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
|
||||
.padding(start = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
|
||||
}
|
||||
}
|
||||
|
||||
private val BANNER_RADIUS = 12.dp
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
/**
|
||||
* Run [flush] when the app goes to the background.
|
||||
*
|
||||
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
|
||||
* activity, which would save mid-sentence for no reason. The lambda goes through
|
||||
* `rememberUpdatedState` so the observer — registered once — always calls the
|
||||
* CURRENT one; captured directly it would hold the first composition's empty text
|
||||
* forever and save that over a full note.
|
||||
*
|
||||
* Shared by the editor and the capture sheet. Both are places where text exists
|
||||
* only in a composable until something writes it down, and the process can be
|
||||
* killed while backgrounded without either of them being told again.
|
||||
*/
|
||||
@Composable
|
||||
fun FlushOnStop(flush: () -> Unit) {
|
||||
val current by rememberUpdatedState(flush)
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) current()
|
||||
}
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
/**
|
||||
* Calls back when the app comes to the front and when it leaves.
|
||||
*
|
||||
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
|
||||
* the editor's save-on-leave makes for the same reason: resume and pause fire for
|
||||
* anything that merely covers the window — a permission dialog, the notification
|
||||
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
|
||||
*
|
||||
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
|
||||
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
|
||||
* between them will read as leaving and returning.
|
||||
*
|
||||
* Two callers, wanting opposite halves of it: automatic sync uses the return to
|
||||
* decide whether to fetch, and the reminder notice uses it to re-read a
|
||||
* permission the person may have just changed in the system settings.
|
||||
*
|
||||
* Both callbacks go through [rememberUpdatedState]: the observer is registered
|
||||
* once, and without it the lambda would keep reading the first composition's
|
||||
* state forever — deciding whether to push unsent notes from a snapshot taken
|
||||
* before any note existed.
|
||||
*/
|
||||
@Composable
|
||||
fun ForegroundTransitions(
|
||||
onForeground: () -> Unit,
|
||||
onBackground: () -> Unit,
|
||||
) {
|
||||
val forward by rememberUpdatedState(onForeground)
|
||||
val away by rememberUpdatedState(onBackground)
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> forward()
|
||||
Lifecycle.Event.ON_STOP -> away()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: Note,
|
||||
onOpen: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Clipped BEFORE clickable, so the ripple is bounded by the card's
|
||||
// rounded corners instead of a rectangle overhanging them.
|
||||
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
|
||||
.background(tint.background(dark))
|
||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2), and
|
||||
// nothing above them: the first line of the body IS the note's name, at the
|
||||
// same weight as the rest of it (M13 steps 3 and 4).
|
||||
if (note.body.isNotBlank()) {
|
||||
Text(
|
||||
text = note.body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = MAX_PREVIEW_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (note.items.isNotEmpty()) {
|
||||
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
|
||||
Checklist(items = note.items)
|
||||
}
|
||||
|
||||
// A note with no body and no items still has to occupy the board legibly —
|
||||
// otherwise it reads as a rendering bug.
|
||||
if (note.body.isBlank() && note.items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelChips(labels = note.labels)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Checklist(items: List<ChecklistItem>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
|
||||
// a live control here would invite taps that the board cannot yet
|
||||
// honour. It becomes interactive with the editor.
|
||||
Text(
|
||||
text = if (item.checked) "☑" else "☐",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
color =
|
||||
if (item.checked) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val hidden = items.size - MAX_CHECKLIST_ROWS
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelChips(labels: List<NoteLabel>) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
// A plain row that clips rather than wraps: a card with eight labels should
|
||||
// not grow taller than its content. The editor shows the full set.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
Text(
|
||||
text = label.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tint.chipForeground(dark),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||
.background(tint.chipBackground(dark))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder, red once it has passed.
|
||||
*
|
||||
* Red for overdue and neutral otherwise, matching the web card exactly — the same
|
||||
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
|
||||
* used to be blue for every reminder here, which made "you missed this" and
|
||||
* "coming up on Friday" look identical on a board full of both.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReminderChip(
|
||||
instant: String,
|
||||
recurrence: String?,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(if (isPast(instant)) "red" else "default")
|
||||
Text(
|
||||
text = reminderLabel(instant, recurrence),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tint.chipForeground(dark),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||
.background(tint.chipBackground(dark))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private const val MAX_PREVIEW_LINES = 8
|
||||
private const val MAX_CHECKLIST_ROWS = 8
|
||||
private const val MAX_LABEL_CHIPS = 3
|
||||
private val CARD_RADIUS = 12.dp
|
||||
private val CHIP_RADIUS = 6.dp
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.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.material3.TopAppBarDefaults
|
||||
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.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The note editor: a full screen, not a sheet.
|
||||
*
|
||||
* A sheet works for capture, where the board behind it is reassurance that the
|
||||
* thought landed somewhere. Editing is different — a sustained task with the
|
||||
* keyboard up — and a sheet would spend the whole time fighting the IME for the
|
||||
* bottom half of the display. Full screen also gives the actions a bottom bar,
|
||||
* which is where a thumb already is.
|
||||
*
|
||||
* The note's own colour paints the WHOLE screen rather than a card inside it, so
|
||||
* opening a note reads as the same object growing to fill the display.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoteEditorScreen(
|
||||
note: Note,
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
|
||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||
// second note opened would show the first one's text.
|
||||
var body by remember(note.id) { mutableStateOf(note.body) }
|
||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||
|
||||
// A note in the trash is a record, not a document: editing one would silently
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
// Restore and Delete forever as the only things to do with it.
|
||||
val readOnly = note.trashed
|
||||
|
||||
// Persist the text, if it changed. The baseline check is what makes "open a
|
||||
// note, read it, back out" write nothing at all — without it every glance
|
||||
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||
// revision identical to the one before it.
|
||||
val flush = {
|
||||
if (!readOnly && body != note.body) {
|
||||
onAction(EditorAction.SaveText(body))
|
||||
}
|
||||
}
|
||||
val leave = {
|
||||
flush()
|
||||
onAction(EditorAction.Close)
|
||||
}
|
||||
|
||||
BackHandler(onBack = leave)
|
||||
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
// without the screen being torn down. Losing a paragraph to an incoming call
|
||||
// is exactly the failure that makes someone stop trusting a notes app.
|
||||
FlushOnStop(flush)
|
||||
|
||||
Scaffold(
|
||||
containerColor = tint.background(dark),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = leave) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
EditorBottomBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
tint = tint,
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// A one-pixel line, not a spinner: a save slow enough to see is worth
|
||||
// showing, and one that isn't must not make the screen jump.
|
||||
if (saving) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// A failed save has to be visible HERE. The board renders the same
|
||||
// banner, but a write that fails while the editor is open would
|
||||
// otherwise report itself only after the user had already left.
|
||||
error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
|
||||
}
|
||||
|
||||
// One field. A note is its body; its NAME is that body's first line, so
|
||||
// there is nothing separate to type into and nothing to render bolder
|
||||
// than the line beneath it (M13 steps 3 and 4).
|
||||
EditorField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
hint = R.string.editor_body_hint,
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
)
|
||||
|
||||
// Below the body, not instead of it, and only once the note has items —
|
||||
// the toolbar's add-checklist action is what puts the first one there.
|
||||
if (note.items.isNotEmpty()) {
|
||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorOverlays(
|
||||
note = note,
|
||||
labels = labels,
|
||||
picker = picker,
|
||||
onPicker = { picker = it },
|
||||
onAction = onAction,
|
||||
)
|
||||
|
||||
if (confirmingDelete) {
|
||||
// The only irreversible action in the app earns the only confirmation in
|
||||
// it. Everything else — archive, trash, even unlinking a server — undoes.
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = false },
|
||||
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
|
||||
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmingDelete = false
|
||||
onAction(EditorAction.DeleteForever)
|
||||
}) {
|
||||
Text(stringResource(R.string.editor_delete_forever_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingDelete = false }) {
|
||||
Text(stringResource(R.string.editor_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
|
||||
enum class Picker { NONE, COLOR, LABELS, REMINDER }
|
||||
|
||||
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
|
||||
@Composable
|
||||
private fun EditorOverlays(
|
||||
note: Note,
|
||||
labels: List<Label>,
|
||||
picker: Picker,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dismiss = { onPicker(Picker.NONE) }
|
||||
when (picker) {
|
||||
Picker.NONE -> Unit
|
||||
Picker.COLOR ->
|
||||
ColorSheet(
|
||||
selected = note.color,
|
||||
onPick = {
|
||||
onAction(EditorAction.SetColor(it))
|
||||
dismiss()
|
||||
},
|
||||
onDismiss = dismiss,
|
||||
)
|
||||
Picker.LABELS ->
|
||||
LabelSheet(
|
||||
note = note,
|
||||
labels = labels,
|
||||
onAction = onAction,
|
||||
onDismiss = dismiss,
|
||||
)
|
||||
Picker.REMINDER ->
|
||||
ReminderSheet(
|
||||
note = note,
|
||||
onAction = onAction,
|
||||
onDismiss = dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's body field.
|
||||
*
|
||||
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
|
||||
* the note's colour, and a filled field would draw a second surface over the first
|
||||
* and turn a note into a form.
|
||||
*
|
||||
* One weight throughout. The first line is the note's name, but it is not a
|
||||
* different KIND of text from the line after it, and typing it should not feel like
|
||||
* filling in a header.
|
||||
*/
|
||||
@Composable
|
||||
private fun EditorField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
@StringRes hint: Int,
|
||||
enabled: Boolean,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
PlainTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
hint = hint,
|
||||
enabled = enabled,
|
||||
minLines = minLines,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
|
||||
private const val MIN_BODY_LINES = 6
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
|
||||
*
|
||||
* A note's colour is stored by the core as a key ("red", "teal", …) and every
|
||||
* surface resolves it to its own tints. The web app resolves through Tailwind
|
||||
* classes; this table is those same Tailwind colours as literals, so a note that
|
||||
* is amber on the desktop is the same amber on the phone rather than a near-miss.
|
||||
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
|
||||
*
|
||||
* Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a
|
||||
* precomputed blend — Compose composites a translucent colour over what's beneath
|
||||
* exactly as CSS does, so the card sits on the background the same way in both.
|
||||
*
|
||||
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
|
||||
* acid against the neutral surfaces.
|
||||
*/
|
||||
data class NoteTint(
|
||||
val label: String,
|
||||
val lightBackground: Color,
|
||||
val lightBorder: Color,
|
||||
val darkBackground: Color,
|
||||
val darkBorder: Color,
|
||||
val lightChipBackground: Color,
|
||||
val lightChipForeground: Color,
|
||||
val darkChipBackground: Color,
|
||||
val darkChipForeground: Color,
|
||||
) {
|
||||
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
||||
|
||||
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
|
||||
|
||||
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
|
||||
|
||||
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||
}
|
||||
|
||||
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
|
||||
val NOTE_TINTS: Map<String, NoteTint> =
|
||||
mapOf(
|
||||
"default" to
|
||||
NoteTint(
|
||||
label = "Default",
|
||||
lightBackground = Color(0xFFFFFFFF),
|
||||
lightBorder = Color(0xFFE5E5E5),
|
||||
darkBackground = Color(0xFF171717),
|
||||
darkBorder = Color(0xFF404040),
|
||||
lightChipBackground = Color(0x0D000000),
|
||||
lightChipForeground = Color(0xFF525252),
|
||||
darkChipBackground = Color(0x1AFFFFFF),
|
||||
darkChipForeground = Color(0xFFD4D4D4),
|
||||
),
|
||||
"red" to
|
||||
NoteTint(
|
||||
label = "Red",
|
||||
lightBackground = Color(0xFFFEF2F2),
|
||||
lightBorder = Color(0xFFFECACA),
|
||||
darkBackground = Color(0x66450A0A),
|
||||
darkBorder = Color(0xFF7F1D1D),
|
||||
lightChipBackground = Color(0xFFFEE2E2),
|
||||
lightChipForeground = Color(0xFFB91C1C),
|
||||
darkChipBackground = Color(0x80450A0A),
|
||||
darkChipForeground = Color(0xFFFCA5A5),
|
||||
),
|
||||
"orange" to
|
||||
NoteTint(
|
||||
label = "Orange",
|
||||
lightBackground = Color(0xFFFFF7ED),
|
||||
lightBorder = Color(0xFFFED7AA),
|
||||
darkBackground = Color(0x66431407),
|
||||
darkBorder = Color(0xFF7C2D12),
|
||||
lightChipBackground = Color(0xFFFFEDD5),
|
||||
lightChipForeground = Color(0xFFC2410C),
|
||||
darkChipBackground = Color(0x80431407),
|
||||
darkChipForeground = Color(0xFFFDBA74),
|
||||
),
|
||||
"yellow" to
|
||||
NoteTint(
|
||||
label = "Yellow",
|
||||
lightBackground = Color(0xFFFFFBEB),
|
||||
lightBorder = Color(0xFFFDE68A),
|
||||
darkBackground = Color(0x66451A03),
|
||||
darkBorder = Color(0xFF78350F),
|
||||
lightChipBackground = Color(0xFFFEF3C7),
|
||||
lightChipForeground = Color(0xFF92400E),
|
||||
darkChipBackground = Color(0x80451A03),
|
||||
darkChipForeground = Color(0xFFFCD34D),
|
||||
),
|
||||
"green" to
|
||||
NoteTint(
|
||||
label = "Green",
|
||||
lightBackground = Color(0xFFF0FDF4),
|
||||
lightBorder = Color(0xFFBBF7D0),
|
||||
darkBackground = Color(0x66052E16),
|
||||
darkBorder = Color(0xFF14532D),
|
||||
lightChipBackground = Color(0xFFDCFCE7),
|
||||
lightChipForeground = Color(0xFF15803D),
|
||||
darkChipBackground = Color(0x80052E16),
|
||||
darkChipForeground = Color(0xFF86EFAC),
|
||||
),
|
||||
"teal" to
|
||||
NoteTint(
|
||||
label = "Teal",
|
||||
lightBackground = Color(0xFFF0FDFA),
|
||||
lightBorder = Color(0xFF99F6E4),
|
||||
darkBackground = Color(0x66042F2E),
|
||||
darkBorder = Color(0xFF134E4A),
|
||||
lightChipBackground = Color(0xFFCCFBF1),
|
||||
lightChipForeground = Color(0xFF0F766E),
|
||||
darkChipBackground = Color(0x80042F2E),
|
||||
darkChipForeground = Color(0xFF5EEAD4),
|
||||
),
|
||||
"blue" to
|
||||
NoteTint(
|
||||
label = "Blue",
|
||||
lightBackground = Color(0xFFEFF6FF),
|
||||
lightBorder = Color(0xFFBFDBFE),
|
||||
darkBackground = Color(0x66172554),
|
||||
darkBorder = Color(0xFF1E3A8A),
|
||||
lightChipBackground = Color(0xFFDBEAFE),
|
||||
lightChipForeground = Color(0xFF1D4ED8),
|
||||
darkChipBackground = Color(0x80172554),
|
||||
darkChipForeground = Color(0xFF93C5FD),
|
||||
),
|
||||
"purple" to
|
||||
NoteTint(
|
||||
label = "Purple",
|
||||
lightBackground = Color(0xFFFAF5FF),
|
||||
lightBorder = Color(0xFFE9D5FF),
|
||||
darkBackground = Color(0x663B0764),
|
||||
darkBorder = Color(0xFF581C87),
|
||||
lightChipBackground = Color(0xFFF3E8FF),
|
||||
lightChipForeground = Color(0xFF7E22CE),
|
||||
darkChipBackground = Color(0x803B0764),
|
||||
darkChipForeground = Color(0xFFD8B4FE),
|
||||
),
|
||||
"pink" to
|
||||
NoteTint(
|
||||
label = "Pink",
|
||||
lightBackground = Color(0xFFFDF2F8),
|
||||
lightBorder = Color(0xFFFBCFE8),
|
||||
darkBackground = Color(0x66500724),
|
||||
darkBorder = Color(0xFF831843),
|
||||
lightChipBackground = Color(0xFFFCE7F3),
|
||||
lightChipForeground = Color(0xFFBE185D),
|
||||
darkChipBackground = Color(0x80500724),
|
||||
darkChipForeground = Color(0xFFF9A8D4),
|
||||
),
|
||||
"gray" to
|
||||
NoteTint(
|
||||
label = "Gray",
|
||||
lightBackground = Color(0xFFF5F5F5),
|
||||
lightBorder = Color(0xFFD4D4D4),
|
||||
darkBackground = Color(0xFF262626),
|
||||
darkBorder = Color(0xFF404040),
|
||||
lightChipBackground = Color(0xFFE5E5E5),
|
||||
lightChipForeground = Color(0xFF404040),
|
||||
darkChipBackground = Color(0xFF404040),
|
||||
darkChipForeground = Color(0xFFE5E5E5),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a stored colour key.
|
||||
*
|
||||
* An unknown key falls back to `default` rather than throwing: colours are data
|
||||
* that arrives from a server which may be newer than this client, and a note
|
||||
* whose tint we don't recognise should still be readable.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
|
||||
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
/**
|
||||
* A way to FIX what the notice describes, when there is one.
|
||||
*
|
||||
* Separate from [onDismiss] because they are opposites: dismissing accepts the
|
||||
* situation, acting changes it. A notice about a permission has an action and
|
||||
* no dismiss — acknowledging a reminder that cannot ring does not make it ring.
|
||||
*/
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
) {
|
||||
Panel(tone = tone) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||
if (actionLabel != null && onAction != null) {
|
||||
TextButton(onClick = onAction) { Text(actionLabel) }
|
||||
}
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
/**
|
||||
* A text field with no box around it.
|
||||
*
|
||||
* Every writing surface in the app — the capture sheet, the editor's title and
|
||||
* body, each checklist row — sits on a surface that already has its own edges and
|
||||
* its own colour. Material's filled field would draw a second, differently
|
||||
* coloured box inside the first, which makes writing a note look like filling in a
|
||||
* form. Stripping the container and the indicator in four places independently is
|
||||
* how they drift apart, so it happens once, here.
|
||||
*
|
||||
* The disabled colours are stripped too: a trashed note is shown through this
|
||||
* field read-only, and Material's disabled treatment would grey out text the user
|
||||
* is meant to be reading.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlainTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes hint: Int? = null,
|
||||
enabled: Boolean = true,
|
||||
singleLine: Boolean = false,
|
||||
minLines: Int = 1,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
placeholder = hint?.let { { Text(stringResource(it)) } },
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
textStyle = textStyle,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = visualTransformation,
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
// Full-strength, not Material's 38%-alpha disabled treatment: a
|
||||
// trashed note is rendered read-only through this field and its
|
||||
// text is meant to be READ, not visually retired.
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.Reminders
|
||||
|
||||
/**
|
||||
* Says so when a reminder would not actually reach anyone.
|
||||
*
|
||||
* Both conditions here are ones Android can put the app into at any time and
|
||||
* never tells it about: notifications switched off in system settings, and exact
|
||||
* alarms refused. Either one turns reminders into something that silently does
|
||||
* nothing, and a feature that silently does nothing is worse than one that is
|
||||
* plainly absent — the person keeps setting reminders and keeps not getting them.
|
||||
*
|
||||
* Shown only on the Reminders view, which is where somebody is already thinking
|
||||
* about this. Putting it on the main board would nag people who have never set a
|
||||
* reminder at all.
|
||||
*
|
||||
* Re-read on every return to the app, because the fix happens in a system screen
|
||||
* this app cannot observe: without that, someone would grant the permission, come
|
||||
* back, and still be looking at a warning telling them they had not.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderNotice() {
|
||||
val context = LocalContext.current
|
||||
var canNotify by remember { mutableStateOf(notificationsAllowed(context)) }
|
||||
var canBeExact by remember { mutableStateOf(exactAllowed(context)) }
|
||||
|
||||
ForegroundTransitions(
|
||||
onForeground = {
|
||||
canNotify = notificationsAllowed(context)
|
||||
canBeExact = exactAllowed(context)
|
||||
},
|
||||
onBackground = {},
|
||||
)
|
||||
|
||||
if (canNotify && canBeExact) return
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = GUTTER, vertical = 4.dp)) {
|
||||
if (!canNotify) {
|
||||
Notice(
|
||||
tone = Tone.WARN,
|
||||
title = stringResource(R.string.reminder_notifications_blocked_title),
|
||||
body = stringResource(R.string.reminder_notifications_blocked_body),
|
||||
actionLabel = stringResource(R.string.reminder_open_settings),
|
||||
onAction = { context.startActivity(appNotificationSettings(context)) },
|
||||
)
|
||||
}
|
||||
// Only worth raising once notifications work at all: told both at once, the
|
||||
// second is noise about the punctuality of something that is not arriving.
|
||||
if (canNotify && !canBeExact) {
|
||||
Notice(
|
||||
tone = Tone.WARN,
|
||||
title = stringResource(R.string.reminder_inexact_title),
|
||||
body = stringResource(R.string.reminder_inexact_body),
|
||||
actionLabel = stringResource(R.string.reminder_allow_exact),
|
||||
onAction = { context.startActivity(exactAlarmSettings(context)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationsAllowed(context: Context): Boolean =
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
|
||||
private fun exactAllowed(context: Context): Boolean {
|
||||
val alarms = context.getSystemService(AlarmManager::class.java) ?: return true
|
||||
return Reminders.canBeExact(alarms)
|
||||
}
|
||||
|
||||
private fun appNotificationSettings(context: Context): Intent =
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
private fun exactAlarmSettings(context: Context): Intent =
|
||||
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||
.setData(Uri.fromParts("package", context.packageName, null))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
@@ -0,0 +1,367 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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,
|
||||
) {
|
||||
Text(stringResource(R.string.sync_connect))
|
||||
}
|
||||
|
||||
// Where app updates come from, said here rather than left as a gap. This
|
||||
// device has no update path at all until it is linked, and a Check button
|
||||
// that always found nothing would be worse than the sentence.
|
||||
UnlinkedUpdateNote()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,313 @@
|
||||
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.Switch
|
||||
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.Alignment
|
||||
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
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
|
||||
/**
|
||||
* 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,
|
||||
automatic: Boolean,
|
||||
onAutomaticChange: (Boolean) -> Unit,
|
||||
update: UpdateState,
|
||||
onCheckUpdate: () -> Unit,
|
||||
onInstallUpdate: () -> Unit,
|
||||
onDismissUpdateError: () -> Unit,
|
||||
onInstallOutcome: (UpdateOutcome.Result) -> 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,
|
||||
automatic = automatic,
|
||||
onAutomaticChange = onAutomaticChange,
|
||||
update = update,
|
||||
onCheckUpdate = onCheckUpdate,
|
||||
onInstallUpdate = onInstallUpdate,
|
||||
onDismissUpdateError = onDismissUpdateError,
|
||||
onInstallOutcome = onInstallOutcome,
|
||||
)
|
||||
else ->
|
||||
UnlinkedPanel(
|
||||
state = state,
|
||||
onProbe = onProbe,
|
||||
onClearProbe = onClearProbe,
|
||||
onLink = onLink,
|
||||
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────── linked ─────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun LinkedPanel(
|
||||
state: SyncState,
|
||||
onSyncNow: () -> Unit,
|
||||
onUnlink: () -> Unit,
|
||||
automatic: Boolean,
|
||||
onAutomaticChange: (Boolean) -> Unit,
|
||||
update: UpdateState,
|
||||
onCheckUpdate: () -> Unit,
|
||||
onInstallUpdate: () -> Unit,
|
||||
onDismissUpdateError: () -> Unit,
|
||||
onInstallOutcome: (UpdateOutcome.Result) -> 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AutomaticRow(automatic = automatic, enabled = !state.busy, onChange = onAutomaticChange)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// The app itself comes from this server too, not just the notes.
|
||||
UpdateCard(
|
||||
state = update,
|
||||
onCheck = onCheckUpdate,
|
||||
onInstall = onInstallUpdate,
|
||||
onDismissError = onDismissUpdateError,
|
||||
onOutcome = onInstallOutcome,
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one setting this screen has.
|
||||
*
|
||||
* Reads as a statement of what the phone does rather than a feature name, and
|
||||
* says what "automatically" means in minutes — an interval a person cannot see is
|
||||
* one they cannot trust, and "syncs automatically" covers everything from every
|
||||
* keystroke to once a day.
|
||||
*
|
||||
* Turning it off is not turning sync off. The copy says so, because a switch next
|
||||
* to a Disconnect button invites exactly that reading.
|
||||
*/
|
||||
@Composable
|
||||
private fun AutomaticRow(
|
||||
automatic: Boolean,
|
||||
enabled: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.sync_automatic),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (automatic) {
|
||||
R.string.sync_automatic_on
|
||||
} else {
|
||||
R.string.sync_automatic_off
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = automatic, onCheckedChange = onChange, enabled = enabled)
|
||||
}
|
||||
}
|
||||
@@ -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,350 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** A sync the person asked for. Failures are reported. */
|
||||
fun syncNow() = sync(announce = true)
|
||||
|
||||
/**
|
||||
* A sync nothing asked for — app resume, or the periodic worker.
|
||||
*
|
||||
* The difference is entirely in how FAILURE is treated. Someone who pulled
|
||||
* the board down is owed an answer; someone who merely opened the app did not
|
||||
* ask a question, and answering it with a red banner about a server being
|
||||
* unreachable makes their own notes look broken when nothing of theirs is.
|
||||
* The quiet channel for a persistent problem is the drawer badge, which reads
|
||||
* `has_pending` and does not care how the attempt was made.
|
||||
*
|
||||
* It does NOT clear an existing error either: a failure the person was already
|
||||
* shown stays shown until they dismiss it or a real sync succeeds.
|
||||
*/
|
||||
fun syncQuietly() = sync(announce = false)
|
||||
|
||||
private fun sync(announce: Boolean) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(syncing = true, syncError = if (announce) null else state.syncError)
|
||||
state =
|
||||
try {
|
||||
val outcome = core.syncNow()
|
||||
state.copy(
|
||||
syncing = false,
|
||||
// A success clears the error whoever started it: the
|
||||
// condition it described is demonstrably over.
|
||||
syncError = null,
|
||||
lastOutcome = outcome,
|
||||
status = outcome.status,
|
||||
pending = withContext(Dispatchers.IO) { core.hasPending() },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
state.copy(
|
||||
syncing = false,
|
||||
syncError = if (announce) e.describe() else state.syncError,
|
||||
)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge a sync failure.
|
||||
*
|
||||
* Exists because the BOARD reports these too, and a banner the person cannot
|
||||
* get rid of is worse than the failure it describes. Clearing is honest here:
|
||||
* an unsynced note is still pending, `hasPending` still says so, and the next
|
||||
* cycle will report the same fault if it is still there.
|
||||
*/
|
||||
fun dismissSyncError() {
|
||||
state = state.copy(syncError = 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."
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// The brand colour: the same #F5C518 the web app's manifest, its <meta
|
||||
// name="theme-color"> and the adaptive launcher icon all use.
|
||||
private val Brand = Color(0xFFF5C518)
|
||||
|
||||
// Neutral surfaces lifted from the web app's palette so the three clients share a
|
||||
// ground, not just an accent. style.css paints neutral-50 in light and neutral-950
|
||||
// in dark, which is also what the desktop window is painted before the webview
|
||||
// draws its first frame.
|
||||
private val Neutral50 = Color(0xFFFAFAFA)
|
||||
private val Neutral200 = Color(0xFFE5E5E5)
|
||||
private val Neutral500 = Color(0xFF737373)
|
||||
private val Neutral700 = Color(0xFF404040)
|
||||
private val Neutral800 = Color(0xFF262626)
|
||||
private val Neutral900 = Color(0xFF171717)
|
||||
private val Neutral950 = Color(0xFF0A0A0A)
|
||||
private val Ink = Color(0xFF1A1A1A)
|
||||
|
||||
private val LightColors =
|
||||
lightColorScheme(
|
||||
primary = Brand,
|
||||
// Black on gold, never white: the brand colour is bright enough that white
|
||||
// text on it fails contrast outright.
|
||||
onPrimary = Ink,
|
||||
primaryContainer = Brand,
|
||||
onPrimaryContainer = Ink,
|
||||
background = Neutral50,
|
||||
onBackground = Neutral900,
|
||||
surface = Neutral50,
|
||||
onSurface = Neutral900,
|
||||
surfaceVariant = Neutral200,
|
||||
onSurfaceVariant = Neutral700,
|
||||
outline = Neutral500,
|
||||
outlineVariant = Neutral200,
|
||||
)
|
||||
|
||||
private val DarkColors =
|
||||
darkColorScheme(
|
||||
primary = Brand,
|
||||
onPrimary = Ink,
|
||||
primaryContainer = Brand,
|
||||
onPrimaryContainer = Ink,
|
||||
background = Neutral950,
|
||||
onBackground = Neutral50,
|
||||
surface = Neutral950,
|
||||
onSurface = Neutral50,
|
||||
surfaceVariant = Neutral800,
|
||||
onSurfaceVariant = Neutral200,
|
||||
outline = Neutral500,
|
||||
outlineVariant = Neutral700,
|
||||
)
|
||||
|
||||
/**
|
||||
* Material 3 in ThoughtSync's own colours, following the system light/dark setting.
|
||||
*
|
||||
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
|
||||
* saw the first build. Dynamic colour is the more Android-native choice and it
|
||||
* makes the app look like a different product on the phone than on the desktop and
|
||||
* the web — on a stock device with no wallpaper it renders as undifferentiated
|
||||
* grey. The three surfaces are peers held to one quality bar, so they share one
|
||||
* identity; taking the wallpaper's palette instead would throw that away for
|
||||
* platform convention.
|
||||
*
|
||||
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
|
||||
*/
|
||||
@Composable
|
||||
fun ThoughtSyncTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
// The timestamp seam between the core and the phone.
|
||||
//
|
||||
// The core stores and syncs RFC3339 in UTC, because that is what SQLite holds and
|
||||
// what the server speaks. Deciding how a human should READ an instant is the UI's
|
||||
// job and the answer differs per device, so the conversion lives here — once,
|
||||
// rather than in the card and the editor separately, where the two would
|
||||
// eventually format the same reminder differently.
|
||||
|
||||
/**
|
||||
* Exactly the shape the core writes: UTC, milliseconds, `Z`.
|
||||
*
|
||||
* `Instant.toString()` would also be valid RFC3339, but it varies its precision
|
||||
* with the value — it drops the fractional part on a whole second. Matching the
|
||||
* core's `to_rfc3339_opts(Millis, true)` byte for byte means a reminder set on the
|
||||
* phone is indistinguishable from one set on the desktop, including to anything
|
||||
* downstream that compares the strings rather than parsing them.
|
||||
*/
|
||||
private val RFC3339_UTC: DateTimeFormatter =
|
||||
DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
|
||||
.withZone(ZoneId.of("UTC"))
|
||||
|
||||
/** A local wall-clock time, as the instant the core will store. */
|
||||
fun rfc3339(local: LocalDateTime): String = RFC3339_UTC.format(local.atZone(ZoneId.systemDefault()).toInstant())
|
||||
|
||||
/** An instant from the core, as this device's local wall-clock time. */
|
||||
fun localTime(raw: String): LocalDateTime? =
|
||||
runCatching {
|
||||
OffsetDateTime.parse(raw).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* reminder beats a silently missing one, and the raw value is what someone would
|
||||
* need in order to report it.
|
||||
*/
|
||||
fun formatInstant(raw: String): String =
|
||||
localTime(raw)
|
||||
?.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
|
||||
?: raw
|
||||
|
||||
/** The reminder as one line, with its repeat rule if it has one. */
|
||||
fun reminderLabel(
|
||||
raw: String,
|
||||
recurrence: String?,
|
||||
): String =
|
||||
buildString {
|
||||
append("⏰ ")
|
||||
append(formatInstant(raw))
|
||||
if (!recurrence.isNullOrBlank()) {
|
||||
append(" · ↻ ")
|
||||
append(recurrence)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a stored reminder has already passed, for showing it as overdue. */
|
||||
fun isPast(raw: String): Boolean =
|
||||
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Whether a timestamp is older than [minutes] ago — or absent entirely.
|
||||
*
|
||||
* Null reads as stale, which is the answer that matters at the one call site:
|
||||
* a device that has never completed a sync has the most to gain from one.
|
||||
* Unparseable reads as stale too, for the same reason — guessing "recent" from a
|
||||
* value we could not understand would suppress the sync that might fix it.
|
||||
*/
|
||||
fun olderThan(
|
||||
raw: String?,
|
||||
minutes: Long,
|
||||
): Boolean {
|
||||
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
|
||||
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
@@ -0,0 +1,135 @@
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.AppUpdate
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
|
||||
/**
|
||||
* Updating the app from the server it is linked to.
|
||||
*
|
||||
* Lives on the sync screen because that is what it IS — the server hands out the
|
||||
* client as well as the notes. Putting it in a settings screen of its own would
|
||||
* separate two halves of one relationship.
|
||||
*
|
||||
* Nothing here appears on an unlinked device; [UnlinkedUpdateNote] says why in one
|
||||
* line instead, so the absence reads as a consequence of not being linked rather
|
||||
* than as a missing feature.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateCard(
|
||||
state: UpdateState,
|
||||
onCheck: () -> Unit,
|
||||
onInstall: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
onOutcome: (UpdateOutcome.Result) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// The system answers an install through a BroadcastReceiver, which has no way
|
||||
// back into a view model. This is the seam.
|
||||
UpdateOutcome.latest?.let { result ->
|
||||
LaunchedEffect(result) { onOutcome(result) }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_installed_version, state.installedVersion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
val available = state.available
|
||||
if (available != null) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.update_available,
|
||||
available.version,
|
||||
available.size / BYTES_PER_MB,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else if (state.upToDate) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_current),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.working) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (available == null) {
|
||||
TextButton(onClick = onCheck, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.update_check))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onInstall, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.update_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Android's "install unknown apps" grant is separate from anything in the
|
||||
// manifest and only the person can give it. Said BEFORE a download rather
|
||||
// than after, so nobody spends 55 MiB to be told no.
|
||||
if (available != null && !AppUpdate.canInstall(context)) {
|
||||
Notice(
|
||||
tone = Tone.WARN,
|
||||
title = stringResource(R.string.update_permission_title),
|
||||
body = stringResource(R.string.update_permission_body),
|
||||
actionLabel = stringResource(R.string.update_permission_action),
|
||||
onAction = { context.startActivity(AppUpdate.installPermissionSettings(context)) },
|
||||
)
|
||||
}
|
||||
|
||||
state.error?.let {
|
||||
Notice(
|
||||
tone = Tone.ERROR,
|
||||
title = stringResource(R.string.update_failed_title),
|
||||
body = it,
|
||||
onDismiss = onDismissError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line an unlinked device gets.
|
||||
*
|
||||
* Updates arrive from a linked server, so there is genuinely nothing to offer
|
||||
* here — and a Check button that always found nothing would be worse than saying
|
||||
* so.
|
||||
*/
|
||||
@Composable
|
||||
fun UnlinkedUpdateNote() {
|
||||
Text(
|
||||
text = stringResource(R.string.update_needs_server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Carries the bottom breathing room the connect button used to provide,
|
||||
// now that it is the last thing on the unlinked screen.
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private const val BYTES_PER_MB = 1024 * 1024
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import android.content.Context
|
||||
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.AppUpdate
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.thoughtsync.core.ClientUpdate
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Everything the update card renders from. */
|
||||
data class UpdateState(
|
||||
/** What is running now. Shown even when there is nothing to update to. */
|
||||
val installedVersion: Long = 0,
|
||||
val checking: Boolean = false,
|
||||
/** Only ever set to something NEWER — the core does that comparison. */
|
||||
val available: ClientUpdate? = null,
|
||||
/** A check completed and found nothing. Distinct from "not checked yet". */
|
||||
val upToDate: Boolean = false,
|
||||
val working: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val busy: Boolean get() = checking || working
|
||||
}
|
||||
|
||||
/**
|
||||
* Updating the app from the server it syncs with.
|
||||
*
|
||||
* **Linked-only, and said out loud.** The app is local-first and completely usable
|
||||
* having never touched a server, so an unlinked install has no update path at all.
|
||||
* The card says that rather than offering a Check button that silently finds
|
||||
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
|
||||
*
|
||||
* The core does the network work, not this class: the device token lives in the
|
||||
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
|
||||
* secret this app holds across two languages for no gain.
|
||||
*/
|
||||
class UpdateViewModel(
|
||||
private val core: ThoughtSync,
|
||||
/**
|
||||
* MUST be the application context — it outlives this view model, and holding an
|
||||
* Activity here is the textbook way to leak a window.
|
||||
*/
|
||||
private val context: Context,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
||||
private set
|
||||
|
||||
/** Ask the linked server what it has. */
|
||||
fun check() {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(checking = true, error = null, upToDate = false)
|
||||
state =
|
||||
try {
|
||||
val found = core.clientUpdate(state.installedVersion)
|
||||
state.copy(checking = false, available = found, upToDate = found == null)
|
||||
} catch (e: Exception) {
|
||||
// Broad by intent, as everywhere the core is called: it reports
|
||||
// every failure as one error type carrying a message written to
|
||||
// be read, and a failed check must not take the screen down.
|
||||
state.copy(checking = false, error = e.message ?: FALLBACK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the update and hand it to the system installer.
|
||||
*
|
||||
* One action rather than two buttons: nobody wants a downloaded APK sitting
|
||||
* around as an intermediate state they have to think about.
|
||||
*/
|
||||
fun downloadAndInstall() {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(working = true, error = null)
|
||||
UpdateOutcome.clear()
|
||||
val failure =
|
||||
try {
|
||||
val target = AppUpdate.downloadTarget(context)
|
||||
core.downloadClientUpdate(target.absolutePath)
|
||||
// Off the main thread: this streams ~55 MiB into the session.
|
||||
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: FALLBACK
|
||||
}
|
||||
// `working` stays TRUE on success: the install is still in flight, and
|
||||
// on a silent update this process is about to be replaced. Clearing it
|
||||
// here would flash "ready" a moment before the app disappears.
|
||||
state =
|
||||
if (failure == null) state else state.copy(working = false, error = failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take whatever the system finally said about the install.
|
||||
*
|
||||
* Called from the composition, because the answer arrives at a BroadcastReceiver
|
||||
* the system owns and there is no other way back into this class.
|
||||
*/
|
||||
fun consumeInstallOutcome(result: UpdateOutcome.Result) {
|
||||
UpdateOutcome.clear()
|
||||
state = state.copy(working = false, error = result.error)
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
context: Context,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
UpdateViewModel(core, context.applicationContext) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val FALLBACK = "The update couldn't be checked."
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The status-bar icon for a reminder.
|
||||
|
||||
A flat white silhouette on transparency, because that is the only thing Android
|
||||
renders here — a status-bar icon is used as a MASK, so the launcher icon (which
|
||||
is a full-colour adaptive asset) would come out as a solid white blob. This is
|
||||
the Material bell, matching the icon the editor's reminder button already uses,
|
||||
so the same idea wears the same shape in both places.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||
no legacy raster fallback is needed.
|
||||
|
||||
The foreground is the shared maskable asset the web app already ships
|
||||
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
||||
padding adaptive icons require. Reusing it means the phone, the web app and the
|
||||
desktop all wear the same face rather than three near-misses.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||
no legacy raster fallback is needed.
|
||||
|
||||
The foreground is the shared maskable asset the web app already ships
|
||||
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
||||
padding adaptive icons require. Reusing it means the phone, the web app and the
|
||||
desktop all wear the same face rather than three near-misses.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- The product's brand colour, same value the web app's manifest and
|
||||
<meta name="theme-color"> already use. One source of truth for "what
|
||||
colour is ThoughtSync" across the three surfaces. -->
|
||||
<color name="ic_launcher_background">#F5C518</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,196 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">ThoughtSync</string>
|
||||
|
||||
<!-- Search bar -->
|
||||
<string name="search_hint">Search your notes</string>
|
||||
<string name="search_clear">Clear search</string>
|
||||
<string name="nav_open">Open navigation</string>
|
||||
<string name="nav_labels">Labels</string>
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
<!-- Board -->
|
||||
<string name="board_empty_note">Empty note</string>
|
||||
<plurals name="board_more_items">
|
||||
<item quantity="one">+%d more item</item>
|
||||
<item quantity="other">+%d more items</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Empty states. Each destination says something true of ITSELF; a single
|
||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||
<string name="board_empty_title">Nothing here yet</string>
|
||||
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
|
||||
<string name="empty_search_title">No matches</string>
|
||||
<string name="empty_search_body">Nothing matched “%1$s”.</string>
|
||||
<string name="empty_trash_title">Trash is empty</string>
|
||||
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
|
||||
<string name="empty_archive_title">Nothing archived</string>
|
||||
<string name="empty_archive_body">Archived notes leave the board but stay searchable.</string>
|
||||
<string name="empty_reminders_title">No reminders</string>
|
||||
<string name="empty_reminders_body">Notes with a reminder set will appear here.</string>
|
||||
|
||||
<!-- Editor -->
|
||||
<string name="board_open_note">Open note</string>
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">Note</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
<string name="editor_labels">Labels…</string>
|
||||
<string name="editor_archive">Archive</string>
|
||||
<string name="editor_unarchive">Unarchive</string>
|
||||
<string name="editor_trash">Move to trash</string>
|
||||
<string name="editor_restore">Restore</string>
|
||||
<string name="editor_cancel">Cancel</string>
|
||||
|
||||
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
||||
the copy says exactly that rather than asking "Are you sure?". -->
|
||||
<string name="editor_delete_forever">Delete forever</string>
|
||||
<string name="editor_delete_forever_title">Delete this note?</string>
|
||||
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
||||
<string name="editor_delete_forever_confirm">Delete</string>
|
||||
|
||||
<!-- Pickers -->
|
||||
<string name="color_picker_title">Color</string>
|
||||
<string name="label_picker_title">Labels</string>
|
||||
<string name="label_new_hint">Type a label and press enter</string>
|
||||
<string name="label_from_tag">from #tag</string>
|
||||
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="picker_next">Next</string>
|
||||
<string name="picker_set">Set</string>
|
||||
<string name="picker_time_title">Pick a time</string>
|
||||
|
||||
<!-- Reminders -->
|
||||
<string name="reminder_title">Remind me</string>
|
||||
<string name="reminder_later_today">Later today</string>
|
||||
<string name="reminder_tomorrow">Tomorrow</string>
|
||||
<string name="reminder_next_week">Next week</string>
|
||||
<string name="reminder_pick">Pick a date & time</string>
|
||||
<string name="reminder_clear">Remove reminder</string>
|
||||
<string name="reminder_done">Done</string>
|
||||
<string name="reminder_snooze_hour">Snooze 1h</string>
|
||||
<string name="reminder_snooze_day">Snooze 1d</string>
|
||||
<string name="recurrence_none">Once</string>
|
||||
<string name="recurrence_daily">Daily</string>
|
||||
<string name="recurrence_weekly">Weekly</string>
|
||||
<string name="recurrence_monthly">Monthly</string>
|
||||
<string name="recurrence_yearly">Yearly</string>
|
||||
|
||||
<!-- Store failure -->
|
||||
<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>
|
||||
|
||||
<!-- 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="reminder_channel">Reminders</string>
|
||||
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
|
||||
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
|
||||
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
|
||||
<string name="reminder_open_settings">Open settings</string>
|
||||
<string name="reminder_inexact_title">Reminders may arrive late</string>
|
||||
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
|
||||
<string name="reminder_allow_exact">Allow exact timing</string>
|
||||
<string name="sync_automatic">Sync automatically</string>
|
||||
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</string>
|
||||
<string name="sync_automatic_off">Only when you pull the board down or tap Sync now.</string>
|
||||
<string name="update_installed_version">This app is build %1$d.</string>
|
||||
<string name="update_available">Build %1$s is available (%2$d MB).</string>
|
||||
<string name="update_current">You\'re on the newest build this server has.</string>
|
||||
<string name="update_check">Check for an update</string>
|
||||
<string name="update_install">Update</string>
|
||||
<string name="update_failed_title">The update didn\'t install</string>
|
||||
<string name="update_permission_title">Android needs your permission</string>
|
||||
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||
<string name="update_permission_action">Allow installing</string>
|
||||
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</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 -->
|
||||
<string name="error_dismiss">Dismiss</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
A bare Material3 parent. The real palette is applied in Compose
|
||||
(ui/Theme.kt) so light/dark follows the system without a second source of
|
||||
truth in XML — the same reason the desktop reads its live theme rather
|
||||
than hardcoding a window colour.
|
||||
-->
|
||||
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "thoughtsync-uniffi-bindgen"
|
||||
version = "0.1.0"
|
||||
description = "Generates the Kotlin bindings for thoughtsync-ffi"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
# A crate whose ONLY dependency is uniffi itself.
|
||||
#
|
||||
# This started life as a `[[bin]]` inside thoughtsync-ffi, which failed: building
|
||||
# it compiled that crate and therefore the core, reqwest, native-tls and
|
||||
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
|
||||
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
|
||||
# OpenSSL that ci-rust-android has no reason to carry, and the generator died
|
||||
# with "failed to run custom build command for openssl-sys".
|
||||
#
|
||||
# Adding libssl-dev to the image would have worked and been wrong: a code
|
||||
# generator should not link the app's TLS stack to emit Kotlin. Splitting it out
|
||||
# means the generator compiles ~15 small crates and nothing else.
|
||||
#
|
||||
# Still a WORKSPACE MEMBER, deliberately. That is what keeps `uniffi` here and
|
||||
# `uniffi` linked into the .so on one version from one lockfile — they are two
|
||||
# halves of one ABI, and a separate lockfile is exactly how they would drift.
|
||||
[dependencies]
|
||||
uniffi = { version = "0.32", features = ["cli"] }
|
||||
@@ -0,0 +1,16 @@
|
||||
//! The Kotlin generator.
|
||||
//!
|
||||
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
|
||||
//! generate --library <path/to/libthoughtsync_ffi.so> \
|
||||
//! --language kotlin --out-dir <build/generated/uniffi>
|
||||
//! ```
|
||||
//!
|
||||
//! `--library` mode reads uniffi's metadata straight out of the compiled artifact,
|
||||
//! so the generated bindings can never describe a different version of the Rust
|
||||
//! than the one being packaged.
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
// kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the
|
||||
// older plugin can't cast AGP 9's ApplicationExtension to the removed
|
||||
// BaseExtension. Same conclusion Minstrel reached on this toolchain pair.
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
// ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle
|
||||
// plugins — see the note in gradle/libs.versions.toml.
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Per-rule overrides layered on top of detekt's defaults
|
||||
# (`--build-upon-default-config` on the CLI invocation in the Android lane).
|
||||
#
|
||||
# The pre-2.0 `build:` top-level was removed; failure is controlled by the CLI's
|
||||
# exit code instead.
|
||||
|
||||
naming:
|
||||
# Composables conventionally use PascalCase function names. Matches every
|
||||
# mainstream Compose codebase, and mirrors the ktlint exemption in
|
||||
# android/.editorconfig — the two tools have to agree or one of them is always
|
||||
# wrong.
|
||||
FunctionNaming:
|
||||
ignoreAnnotated:
|
||||
- "Composable"
|
||||
|
||||
style:
|
||||
MagicNumber:
|
||||
ignoreAnnotated:
|
||||
- "Composable"
|
||||
# Colour literals and dp constants are declared as named properties, which is
|
||||
# exactly the "define it as a well-named constant" the rule asks for — the
|
||||
# number simply appears in the declaration itself. Flagging
|
||||
# `private val Brand = Color(0xFFF5C518)` would demand a constant holding the
|
||||
# constant.
|
||||
ignorePropertyDeclaration: true
|
||||
|
||||
complexity:
|
||||
# Compose breaks the PREMISE of both rules below, not just their thresholds.
|
||||
#
|
||||
# * LongParameterList assumes a long list means an over-general function. A
|
||||
# composable's parameters ARE its UI contract — Material's own TextField
|
||||
# takes twenty — and collapsing them into a parameter object makes the call
|
||||
# site worse, not better, because named arguments are what keep a Compose
|
||||
# tree readable.
|
||||
# * LongMethod assumes length tracks branching. A composable's length tracks
|
||||
# how many ELEMENTS are on the screen; a full-screen editor with a title, a
|
||||
# body, a checklist, labels and a reminder row is long because it renders
|
||||
# five things, and cutting it into five one-call wrappers would add
|
||||
# indirection without removing a single decision.
|
||||
#
|
||||
# Scoped to @Composable rather than disabled: on ordinary functions both rules
|
||||
# are right, and one of them still fires below (see BoardViewModel).
|
||||
LongParameterList:
|
||||
ignoreAnnotated:
|
||||
- "Composable"
|
||||
LongMethod:
|
||||
ignoreAnnotated:
|
||||
- "Composable"
|
||||
|
||||
exceptions:
|
||||
TooGenericExceptionCaught:
|
||||
# Catching broadly is DELIBERATE in these two places, and each site says so.
|
||||
#
|
||||
# * the ViewModel — a note that fails to save must become a visible error
|
||||
# banner, never a crash. Narrowing this would mean an unanticipated
|
||||
# failure takes the app down instead of being reported, which is strictly
|
||||
# worse for the user.
|
||||
# * the Application — the store failing to open is the one thing that must
|
||||
# still let the app start, so it can explain itself.
|
||||
# * the background Worker — it runs with nobody present, so an escaping
|
||||
# exception is a crash report for a job the person never asked for. Every
|
||||
# realistic failure there (no route, server down, token rotating) has the
|
||||
# same right answer, which is Result.retry().
|
||||
# * the reminder BroadcastReceiver — same argument, one step worse: it can be
|
||||
# woken at 3am by an alarm or by BOOT_COMPLETED, and every path inside it
|
||||
# has already logged its own failure by the time this catches anything.
|
||||
# * the self-updater — the install path throws IOException from three
|
||||
# different calls and SecurityException when the "install unknown apps"
|
||||
# grant has been revoked since it was checked. All of them mean one thing
|
||||
# to the person ("it did not install"), and none should take the app down
|
||||
# while it is holding their notes.
|
||||
#
|
||||
# Scoped to those paths rather than disabled globally: elsewhere the rule is
|
||||
# right and still applies.
|
||||
excludes:
|
||||
- "**/ui/**"
|
||||
- "**/ThoughtSyncApplication.kt"
|
||||
- "**/SyncWorker.kt"
|
||||
- "**/ReminderReceiver.kt"
|
||||
- "**/AppUpdate.kt"
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "thoughtsync-ffi"
|
||||
version = "0.1.0"
|
||||
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
# cdylib is the `.so` Android's System.loadLibrary opens. `lib` alongside it so the
|
||||
# bindgen binary below — and this crate's own tests — can use the crate normally;
|
||||
# a cdylib-only crate is unusable from Rust.
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "thoughtsync_ffi"
|
||||
|
||||
[dependencies]
|
||||
thoughtsync-core = { path = "../../core" }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# tokio lets an exported `async fn` be driven by a tokio runtime, which the sync
|
||||
# engine needs: it is reqwest all the way down.
|
||||
uniffi = { version = "0.32", features = ["tokio"] }
|
||||
|
||||
# reqwest requires a reactor; uniffi's `async_runtime = "tokio"` needs one to exist.
|
||||
# rt-multi-thread rather than current_thread: a sync cycle is network-bound and a
|
||||
# Compose UI may have more than one call in flight.
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
# Display + Error impls for the error enum uniffi turns into a Kotlin exception.
|
||||
thiserror = "2"
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
|
||||
//!
|
||||
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
||||
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
||||
//! starts making decisions about notes or sync, it belongs in the core where the
|
||||
//! desktop gets it too (Scribe note 2730).
|
||||
//!
|
||||
//! ## Shape
|
||||
//!
|
||||
//! One `ThoughtSync` object holds the store and the blob directory, mirroring how
|
||||
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
|
||||
//! process lifetime, and calls methods on it.
|
||||
//!
|
||||
//! ## Async
|
||||
//!
|
||||
//! The sync engine is reqwest all the way down, so it needs a reactor. Async methods
|
||||
//! are exported with `async_runtime = "tokio"`, which uniffi turns into Kotlin
|
||||
//! `suspend` functions driven by a tokio runtime on the Rust side.
|
||||
//!
|
||||
//! Cancellation works, and not by accident: when a coroutine is cancelled uniffi
|
||||
//! drops the Rust future, and none of the core's async paths hold the store lock
|
||||
//! across an `await` — a `std::sync::MutexGuard` isn't `Send`, so the compiler has
|
||||
//! been enforcing that all along. A cancelled sync therefore leaves the store
|
||||
//! consistent; it simply hasn't stamped `last_sync_at`, which is only written after
|
||||
//! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor.
|
||||
//!
|
||||
//! ## A known consequence of the release profile
|
||||
//!
|
||||
//! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi
|
||||
//! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as
|
||||
//! an exception; with `abort` it takes the process down instead. That is the same
|
||||
//! behaviour the desktop already has, so no surface is worse off than another — but
|
||||
//! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever
|
||||
//! turns out to be recoverable enough that a phone should survive it.
|
||||
|
||||
pub mod models;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use thoughtsync_core::local::{self, Db};
|
||||
use thoughtsync_core::sync::blobs::BlobStore;
|
||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||
|
||||
use models::{
|
||||
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
|
||||
RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
/// Everything that can go wrong, as a Kotlin exception.
|
||||
///
|
||||
/// The core reports failures as plain `String`s today, so most of them land in
|
||||
/// `Store` or `Network` by where they were raised rather than by a distinction the
|
||||
/// core actually draws. `NotLinked` is the exception and earns its own variant: it
|
||||
/// is the one failure that is a NORMAL state rather than a fault — an unlinked app is
|
||||
/// working exactly as intended — and the UI's response is to offer linking, not to
|
||||
/// show an error.
|
||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||
// FLAT, so the Kotlin side gets the message on `Throwable` where it belongs.
|
||||
//
|
||||
// Without this, uniffi generates an exception subclass with a `message` PROPERTY
|
||||
// per variant — which collides with `Throwable.message` and fails to compile:
|
||||
// "'message' hides member of supertype 'Throwable' and needs an 'override'
|
||||
// modifier". Renaming the field would dodge the collision but leave
|
||||
// `e.message` null in Kotlin, so every call site would have to know the variant
|
||||
// just to read the text.
|
||||
//
|
||||
// Flat keeps what actually matters: each variant is still its own Kotlin
|
||||
// subclass, so `catch (e: CoreException.NotLinked)` still works and a `when` is
|
||||
// still exhaustive. Only the FIELDS stop crossing, and the Display string —
|
||||
// which is the field, for every variant that has one — comes through as the
|
||||
// exception message.
|
||||
#[uniffi(flat_error)]
|
||||
pub enum CoreError {
|
||||
/// No server is linked. Not a fault; the app is local-first and this is its
|
||||
/// resting state.
|
||||
#[error("this device isn't linked to a server")]
|
||||
NotLinked,
|
||||
|
||||
/// The on-device store failed.
|
||||
#[error("{message}")]
|
||||
Store { message: String },
|
||||
|
||||
/// Talking to the server failed, or it refused.
|
||||
#[error("{message}")]
|
||||
Network { message: String },
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
fn store(e: impl std::fmt::Display) -> Self {
|
||||
CoreError::Store {
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn network(e: impl std::fmt::Display) -> Self {
|
||||
CoreError::Network {
|
||||
message: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client handle: the on-device store plus the attachment directory beside it.
|
||||
///
|
||||
/// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store
|
||||
/// behind its mutex, the blob store being a path — which is what lets uniffi share
|
||||
/// one instance across coroutines.
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct ThoughtSync {
|
||||
db: Db,
|
||||
blobs: BlobStore,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
impl ThoughtSync {
|
||||
/// Open (creating on first run) the store under `data_dir`, and the attachment
|
||||
/// directory beside it.
|
||||
///
|
||||
/// `data_dir` comes from Kotlin because only Android knows where its app-private
|
||||
/// storage is; the core must not guess at a platform path. The layout inside is
|
||||
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
|
||||
/// `blobs/` — so a store is readable by any client that opens it.
|
||||
#[uniffi::constructor]
|
||||
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
|
||||
let dir = PathBuf::from(data_dir);
|
||||
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
|
||||
|
||||
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
|
||||
log::info!("local store ready — {}", local::summary(&db));
|
||||
|
||||
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
|
||||
Ok(Arc::new(ThoughtSync { db, blobs }))
|
||||
}
|
||||
|
||||
/// A one-line count summary, for the boot log.
|
||||
pub fn summary(&self) -> String {
|
||||
local::summary(&self.db)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── notes ───────────────────────────────
|
||||
|
||||
pub fn list_notes(&self, query: NoteQuery) -> Result<Vec<Note>, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let notes = local::store::list_notes(&conn, &query.into()).map_err(CoreError::store)?;
|
||||
Ok(notes.into_iter().map(Note::from).collect())
|
||||
}
|
||||
|
||||
pub fn get_note(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::get_note(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn create_note(&self, draft: NoteDraft) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::create_note(&conn, &draft.into())
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Apply a batch of field edits. See `NoteEdit` for why this is a list rather
|
||||
/// than a struct of nullable fields.
|
||||
pub fn update_note(&self, id: String, edits: Vec<NoteEdit>) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::update_note(&conn, &id, &patch_from(edits))
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Full-text search across titles, bodies and checklist items.
|
||||
///
|
||||
/// The core owns the query — it searches the same columns the desktop and web
|
||||
/// search, so "what matches" cannot drift between surfaces. Filtering the
|
||||
/// board list in Kotlin would have been less code and a different product.
|
||||
pub fn search_notes(&self, query: String) -> Result<Vec<Note>, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let notes = local::store::search(&conn, &query).map_err(CoreError::store)?;
|
||||
Ok(notes.into_iter().map(Note::from).collect())
|
||||
}
|
||||
|
||||
/// Notes carrying a reminder, soonest first.
|
||||
///
|
||||
/// A dedicated call rather than a board `view`, because that is how the core
|
||||
/// models it — `list_notes` only understands trashed/archived/default.
|
||||
pub fn reminder_notes(&self) -> Result<Vec<Note>, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let notes = local::store::reminders(&conn).map_err(CoreError::store)?;
|
||||
Ok(notes.into_iter().map(Note::from).collect())
|
||||
}
|
||||
|
||||
/// Every label with its note count, for the navigation drawer.
|
||||
pub fn list_labels(&self) -> Result<Vec<Label>, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let labels = local::store::list_labels(&conn).map_err(CoreError::store)?;
|
||||
Ok(labels.into_iter().map(Label::from).collect())
|
||||
}
|
||||
|
||||
pub fn trash_note(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::trash(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
pub fn restore_note(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::restore(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Remove a note permanently.
|
||||
///
|
||||
/// Returns nothing, unlike every other mutation here: there is no note left to
|
||||
/// return. The core also records a pending delete, so a linked device tells the
|
||||
/// server rather than having the next pull resurrect the row.
|
||||
pub fn delete_note_forever(&self, id: String) -> Result<(), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ──────────────────────────── checklist items ────────────────────────────
|
||||
//
|
||||
// Every one of these returns the whole reloaded note rather than the item it
|
||||
// touched. That is the core's shape, and it is the right one for a UI: ticking
|
||||
// a box changes `updated_at` and can change what the board shows, so handing
|
||||
// back only the item would leave Kotlin to guess at the rest.
|
||||
|
||||
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::add_item(&conn, ¬e_id, &text)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Retitle one item.
|
||||
///
|
||||
/// Split from `set_item_checked` rather than exposing the core's
|
||||
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
|
||||
/// optional-field struct cannot say "leave this alone" in Kotlin without
|
||||
/// colliding with "set it to null", and two unambiguous calls beat one
|
||||
/// ambiguous one when each is three lines.
|
||||
pub fn set_item_text(
|
||||
&self,
|
||||
note_id: String,
|
||||
item_id: String,
|
||||
text: String,
|
||||
) -> Result<Note, CoreError> {
|
||||
self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text }))
|
||||
}
|
||||
|
||||
pub fn set_item_checked(
|
||||
&self,
|
||||
note_id: String,
|
||||
item_id: String,
|
||||
checked: bool,
|
||||
) -> Result<Note, CoreError> {
|
||||
self.patch_item(
|
||||
¬e_id,
|
||||
&item_id,
|
||||
serde_json::json!({ "checked": checked }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_item(&conn, ¬e_id, &item_id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── reminders ───────────────────────────────
|
||||
|
||||
/// Clear the reminder, marking it dealt with.
|
||||
///
|
||||
/// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same
|
||||
/// thing: the core reserves this one for "the reminder fired and is finished",
|
||||
/// which is where recurrence advancement lands when it is built. A UI that
|
||||
/// called the generic clear instead would silently stop recurring reminders
|
||||
/// from recurring the day that changes.
|
||||
pub fn complete_reminder(&self, id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::complete_reminder(&conn, &id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Push the reminder out by `minutes` from now.
|
||||
///
|
||||
/// The core computes the new instant from its own clock rather than taking one
|
||||
/// from the caller — so "in an hour" means the same thing on every surface,
|
||||
/// and a phone with a skewed clock can't write a reminder the server reads as
|
||||
/// already past.
|
||||
pub fn snooze_reminder(&self, id: String, minutes: i64) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::snooze_reminder(&conn, &id, minutes)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── labels ────────────────────────────────
|
||||
|
||||
/// Replace the note's MANUAL labels.
|
||||
///
|
||||
/// `#tag` labels are owned by the body text and the core re-derives them on
|
||||
/// every body edit, so they are deliberately untouched here. A picker that
|
||||
/// sent the full visible set would strip a tag label the text still mandates —
|
||||
/// and the next keystroke in the body would put it straight back, which is the
|
||||
/// kind of fight a UI should never pick with its store.
|
||||
pub fn set_note_labels(
|
||||
&self,
|
||||
note_id: String,
|
||||
label_ids: Vec<String>,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::set_labels(&conn, ¬e_id, &label_ids)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Find or create a label by name, returning it either way.
|
||||
///
|
||||
/// Find-or-create rather than create: the core matches case-insensitively, so
|
||||
/// typing "Errands" when "errands" exists has to attach the existing label
|
||||
/// instead of minting a near-duplicate that then diverges on colour.
|
||||
pub fn create_label(&self, name: String) -> Result<Label, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::create_label(&conn, &name)
|
||||
.map(Label::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
state::status(&conn)
|
||||
.map(SyncStatus::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Whether anything is waiting to be sent — so the UI can show an honest
|
||||
/// "unsynced changes" state without running a sync to find out.
|
||||
pub fn has_pending(&self) -> Result<bool, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
push::has_pending(&conn).map_err(CoreError::store)
|
||||
}
|
||||
}
|
||||
|
||||
/// Async methods, driven by a tokio runtime and surfaced to Kotlin as `suspend`
|
||||
/// functions. Split into its own impl block so the runtime attribute — and the fact
|
||||
/// that everything in here touches the network — is visible at a glance.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl ThoughtSync {
|
||||
/// Ask a server who it is, without committing to anything. Called as the user
|
||||
/// finishes typing an address, so they see what answered before handing over
|
||||
/// credentials.
|
||||
pub async fn probe(&self, url: String) -> Result<ProbeResult, CoreError> {
|
||||
client::probe(&url)
|
||||
.await
|
||||
.map(ProbeResult::from)
|
||||
.map_err(CoreError::network)
|
||||
}
|
||||
|
||||
/// Pair with a server using an email/password, minting a device token named for
|
||||
/// this phone.
|
||||
///
|
||||
/// The handshake runs FIRST, and an incompatible server aborts before any
|
||||
/// credential is sent — an incompatible server is exactly the case where a later
|
||||
/// failure would be hardest to attribute.
|
||||
pub async fn link_with_password(
|
||||
&self,
|
||||
url: String,
|
||||
email: String,
|
||||
password: String,
|
||||
device_name: String,
|
||||
) -> Result<Identity, CoreError> {
|
||||
let probe = client::probe(&url).await.map_err(CoreError::network)?;
|
||||
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
||||
return Err(CoreError::Network {
|
||||
message: reason.clone(),
|
||||
});
|
||||
}
|
||||
let (token, identity) =
|
||||
client::device_login(&probe.base_url, &email, &password, &device_name)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
|
||||
Ok(identity.into())
|
||||
}
|
||||
|
||||
/// Pair using a device token pasted from the web app — for anyone who would
|
||||
/// rather not type a password into an app, or whose account is behind SSO.
|
||||
///
|
||||
/// The token is verified before it is stored, so a copy/paste slip fails here
|
||||
/// rather than at the next sync.
|
||||
pub async fn link_with_token(&self, url: String, token: String) -> Result<Identity, CoreError> {
|
||||
let probe = client::probe(&url).await.map_err(CoreError::network)?;
|
||||
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
||||
return Err(CoreError::Network {
|
||||
message: reason.clone(),
|
||||
});
|
||||
}
|
||||
let identity = client::fetch_identity(&probe.base_url, &token)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
|
||||
Ok(identity.into())
|
||||
}
|
||||
|
||||
/// Stop syncing, and retire this device's token on the server.
|
||||
///
|
||||
/// The local half is unconditional. Someone unlinking because the phone is being
|
||||
/// sold or handed on must not be held to it by a server that is offline or gone,
|
||||
/// so the revoke is attempted first, its outcome returned for the UI to report
|
||||
/// honestly, and the link cleared either way.
|
||||
pub async fn unlink(&self) -> Result<RevokeOutcome, CoreError> {
|
||||
// Read and release before the network call: a std MutexGuard isn't Send, so
|
||||
// it cannot be held across an await, and holding the store through a
|
||||
// round-trip would freeze every note operation in the UI.
|
||||
let link = {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let current = state::read(&conn).map_err(CoreError::store)?;
|
||||
current.server_url.zip(current.device_token)
|
||||
};
|
||||
let revoked = match &link {
|
||||
Some((base_url, token)) => client::revoke_self(base_url, token).await,
|
||||
None => client::RevokeOutcome::Skipped,
|
||||
};
|
||||
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
state::clear_link(&conn).map_err(CoreError::store)?;
|
||||
log::info!("unlinked from server (server-side token: {revoked:?})");
|
||||
Ok(revoked.into())
|
||||
}
|
||||
|
||||
/// Run one full sync: push local changes, then pull the server's.
|
||||
///
|
||||
/// The only sync entry point, on purpose. Push and pull exist separately inside
|
||||
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
|
||||
/// edits — the ordering isn't a suggestion, it's what keeps them.
|
||||
/// The Android client the linked server is offering, if any.
|
||||
///
|
||||
/// `None` covers two different-looking situations that are one answer to the
|
||||
/// app: this server has no client, or it has one and it is not newer than what
|
||||
/// is already installed. Comparing here rather than in Kotlin keeps the rule —
|
||||
/// version CODE decides, never the name — in the layer that also has to get it
|
||||
/// right for the desktop.
|
||||
pub async fn client_update(
|
||||
&self,
|
||||
installed_version_code: i64,
|
||||
) -> Result<Option<ClientUpdate>, CoreError> {
|
||||
let (base_url, token) = self.credentials()?;
|
||||
let release = client::fetch_client_release(&base_url, &token)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(release
|
||||
.filter(|r| r.version_code > installed_version_code)
|
||||
.map(ClientUpdate::from))
|
||||
}
|
||||
|
||||
/// Download that client to `dest_path`, verified.
|
||||
///
|
||||
/// Takes the destination rather than choosing one: only Android knows a
|
||||
/// directory its own package installer can read from, and the core has no
|
||||
/// business guessing at platform paths — the same reason `ThoughtSync::new`
|
||||
/// takes a data dir.
|
||||
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
|
||||
let (base_url, token) = self.credentials()?;
|
||||
let release = client::fetch_client_release(&base_url, &token)
|
||||
.await
|
||||
.map_err(CoreError::network)?
|
||||
// Re-read rather than trusting what the caller was shown: the server
|
||||
// may have published a new build between the check and the tap, and
|
||||
// downloading against a stale digest would fail verification on bytes
|
||||
// that are perfectly good.
|
||||
.ok_or_else(|| {
|
||||
CoreError::network("This server no longer has an Android client.".to_string())
|
||||
})?;
|
||||
client::download_client(
|
||||
&base_url,
|
||||
&token,
|
||||
&release,
|
||||
std::path::Path::new(&dest_path),
|
||||
)
|
||||
.await
|
||||
.map_err(CoreError::network)
|
||||
}
|
||||
|
||||
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
|
||||
let (base_url, token) = self.credentials()?;
|
||||
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
|
||||
.await
|
||||
.map(SyncOutcome::from)
|
||||
.map_err(CoreError::network)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
/// Apply a `{text}` or `{checked}` patch to one checklist item.
|
||||
///
|
||||
/// The two public setters differ only in the key they write, and the lock +
|
||||
/// convert + map-error dance around it is identical, so it lives once here.
|
||||
fn patch_item(
|
||||
&self,
|
||||
note_id: &str,
|
||||
item_id: &str,
|
||||
changes: serde_json::Value,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::update_item(&conn, note_id, item_id, &changes)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
||||
/// exactly this, and none of them may hold the lock past it.
|
||||
fn credentials(&self) -> Result<(String, String), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
let current = state::read(&conn).map_err(CoreError::store)?;
|
||||
match (current.server_url, current.device_token) {
|
||||
(Some(url), Some(token)) => Ok((url, token)),
|
||||
_ => Err(CoreError::NotLinked),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a fresh link, adopting the server's retention window at the same time
|
||||
/// so the Trash view stops counting down against this device's offline default
|
||||
/// the moment it is no longer the policy in force.
|
||||
fn store_link(
|
||||
&self,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
retention_days: Option<u32>,
|
||||
) -> Result<(), CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
state::set_link(&conn, base_url, token).map_err(CoreError::store)?;
|
||||
if let Some(days) = retention_days {
|
||||
state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?;
|
||||
}
|
||||
log::info!("linked to {base_url}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A scratch directory unique to this process and call.
|
||||
///
|
||||
/// Process id + a counter rather than a uuid dependency: the FFI crate has no
|
||||
/// business pulling one in to name a temp folder, and this is the same approach
|
||||
/// the desktop's updater tests settled on.
|
||||
fn scratch_dir() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"thoughtsync-ffi-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn draft(body: &str) -> NoteDraft {
|
||||
NoteDraft {
|
||||
body: body.to_string(),
|
||||
color: "default".to_string(),
|
||||
items: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The round trip the Android skeleton has to make: open a store in a directory
|
||||
/// that doesn't exist yet, write a note, read it back through the FFI types.
|
||||
/// Proving it here means a failure on device is an Android problem, not a
|
||||
/// binding problem.
|
||||
#[test]
|
||||
fn creates_a_store_and_round_trips_a_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("Groceries\nmilk"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.body, "Groceries\nmilk");
|
||||
|
||||
let fetched = app
|
||||
.get_note(created.id.clone())
|
||||
.expect("get should succeed");
|
||||
assert_eq!(fetched.id, created.id);
|
||||
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
||||
assert_eq!(fetched.display_title, "Groceries");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Every note has to be nameable — that is what `display_title` is for, and the
|
||||
/// Android board relies on it exactly as the desktop does.
|
||||
#[test]
|
||||
fn a_note_is_named_by_its_first_line() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("just a thought"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.display_title, "just a thought");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The hole that made removing the title unsafe until checklists stopped being
|
||||
/// their own kind of thing: a note with no body text still needs a name.
|
||||
#[test]
|
||||
fn a_note_with_only_items_is_named_by_its_first_item() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(NoteDraft {
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
||||
})
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.display_title, "milk");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// An unlinked app is a normal, working app. Asking it to sync is the one
|
||||
/// failure that isn't a fault, and it has to arrive as `NotLinked` so the UI can
|
||||
/// offer linking rather than show an error.
|
||||
#[test]
|
||||
fn syncing_unlinked_reports_not_linked() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let status = app.sync_status().expect("status should read");
|
||||
assert!(!status.linked);
|
||||
assert_eq!(status.server_url, None);
|
||||
|
||||
assert!(matches!(app.credentials(), Err(CoreError::NotLinked)));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
|
||||
/// it, drop it. Each call returns the reloaded note, which is what the UI
|
||||
/// splices back into the board rather than re-querying.
|
||||
#[test]
|
||||
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(NoteDraft {
|
||||
body: "Packing".to_string(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["socks".to_string()]),
|
||||
})
|
||||
.expect("create");
|
||||
assert_eq!(note.items.len(), 1);
|
||||
|
||||
let with_two = app
|
||||
.add_item(note.id.clone(), "charger".to_string())
|
||||
.expect("add");
|
||||
assert_eq!(with_two.items.len(), 2);
|
||||
// Appended, not prepended — a new row belongs at the bottom of the list the
|
||||
// user is looking at.
|
||||
assert_eq!(with_two.items[1].text, "charger");
|
||||
|
||||
let item_id = with_two.items[1].id.clone();
|
||||
let ticked = app
|
||||
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
||||
.expect("tick");
|
||||
assert!(ticked.items[1].checked);
|
||||
assert_eq!(
|
||||
ticked.items[1].text, "charger",
|
||||
"ticking a box must not disturb its text — the two setters write \
|
||||
different columns and neither may clear the other"
|
||||
);
|
||||
|
||||
let renamed = app
|
||||
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
|
||||
.expect("rename");
|
||||
assert_eq!(renamed.items[1].text, "usb-c cable");
|
||||
assert!(
|
||||
renamed.items[1].checked,
|
||||
"and the same in the other direction"
|
||||
);
|
||||
|
||||
let trimmed = app
|
||||
.delete_item(note.id.clone(), item_id)
|
||||
.expect("delete item");
|
||||
assert_eq!(trimmed.items.len(), 1);
|
||||
assert_eq!(trimmed.items[0].text, "socks");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A `#tag` in the body owns its label. The picker replaces MANUAL labels only,
|
||||
/// so sending an empty set must not strip one the text still mandates —
|
||||
/// otherwise the next body edit would re-derive it and the UI would appear to
|
||||
/// fight itself.
|
||||
#[test]
|
||||
fn setting_labels_leaves_tag_derived_ones_alone() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let note = app
|
||||
.create_note(draft("Trip\nbook the ferry #travel"))
|
||||
.expect("create");
|
||||
assert_eq!(
|
||||
note.labels.len(),
|
||||
1,
|
||||
"the #tag should have attached a label"
|
||||
);
|
||||
assert!(note.labels[0].via_tag);
|
||||
|
||||
let errands = app
|
||||
.create_label("errands".to_string())
|
||||
.expect("create label");
|
||||
let tagged = app
|
||||
.set_note_labels(note.id.clone(), vec![errands.id.clone()])
|
||||
.expect("set labels");
|
||||
assert_eq!(tagged.labels.len(), 2);
|
||||
|
||||
let cleared = app
|
||||
.set_note_labels(note.id.clone(), vec![])
|
||||
.expect("clear manual labels");
|
||||
assert_eq!(cleared.labels.len(), 1);
|
||||
assert!(cleared.labels[0].via_tag);
|
||||
|
||||
// Find-or-create, not create: a second "Errands" must be the same label,
|
||||
// or the picker mints near-duplicates that then diverge on colour.
|
||||
let again = app
|
||||
.create_label("Errands".to_string())
|
||||
.expect("create label again");
|
||||
assert_eq!(again.id, errands.id);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Deleting forever has to actually remove the row, and the note must then be
|
||||
/// unreadable rather than merely hidden.
|
||||
#[test]
|
||||
fn deleting_forever_removes_the_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
||||
|
||||
app.delete_note_forever(note.id.clone())
|
||||
.expect("delete forever");
|
||||
assert!(
|
||||
app.get_note(note.id.clone()).is_err(),
|
||||
"a permanently deleted note must not still load"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
||||
#[test]
|
||||
fn reminders_can_be_snoozed_and_completed() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Call back")).expect("create");
|
||||
assert_eq!(note.remind_at, None);
|
||||
|
||||
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
||||
let at = snoozed.remind_at.expect("snoozing must set a reminder");
|
||||
let parsed = chrono_free_parse(&at);
|
||||
assert!(
|
||||
parsed > 0,
|
||||
"the reminder must be a parseable RFC3339 instant, got {at:?}"
|
||||
);
|
||||
|
||||
let done = app.complete_reminder(note.id.clone()).expect("complete");
|
||||
assert_eq!(done.remind_at, None);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The path the notification's Done button takes.
|
||||
///
|
||||
/// Completing a RECURRING reminder must move it, not end it — this is the
|
||||
/// behaviour the web has had all along and the clients did not, which made
|
||||
/// "Done" on a daily reminder quietly the last time it ever fired.
|
||||
#[test]
|
||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Water the plants")).expect("create");
|
||||
|
||||
let armed = app
|
||||
.update_note(
|
||||
note.id.clone(),
|
||||
vec![
|
||||
NoteEdit::RemindAt {
|
||||
value: "2026-07-01T09:00:00.000Z".into(),
|
||||
},
|
||||
NoteEdit::Recurrence {
|
||||
value: "daily".into(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("arm a daily reminder");
|
||||
assert_eq!(armed.recurrence.as_deref(), Some("daily"));
|
||||
|
||||
let done = app.complete_reminder(note.id.clone()).expect("complete");
|
||||
let next = done
|
||||
.remind_at
|
||||
.expect("a daily reminder must still have a next occurrence");
|
||||
assert!(
|
||||
next.as_str() > "2026-07-01T09:00:00.000Z",
|
||||
"it must move FORWARD, got {next:?}"
|
||||
);
|
||||
assert!(
|
||||
next.ends_with("T09:00:00.000Z"),
|
||||
"the time of day is what was asked for and must survive, got {next:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
done.recurrence.as_deref(),
|
||||
Some("daily"),
|
||||
"the rule outlives the occurrence"
|
||||
);
|
||||
|
||||
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
||||
// invisibly on a note with no reminder.
|
||||
let once = app.create_note(draft("Post the letter")).expect("create");
|
||||
app.update_note(
|
||||
once.id.clone(),
|
||||
vec![NoteEdit::RemindAt {
|
||||
value: "2026-07-01T09:00:00.000Z".into(),
|
||||
}],
|
||||
)
|
||||
.expect("arm a one-off");
|
||||
let finished = app.complete_reminder(once.id.clone()).expect("complete");
|
||||
assert_eq!(finished.remind_at, None);
|
||||
assert_eq!(finished.recurrence, None);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
||||
/// crate's dev-dependencies to assert one field is well-formed.
|
||||
fn chrono_free_parse(raw: &str) -> usize {
|
||||
if raw.len() >= 20 && raw.as_bytes()[4] == b'-' && raw.contains('T') {
|
||||
raw.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! The types that cross into Kotlin.
|
||||
//!
|
||||
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
|
||||
//! shapes are serde structs whose field names and optionality are contracted with the
|
||||
//! shared Vue frontend; hanging uniffi derives on them would couple two very
|
||||
//! different consumers to one definition and put a `serde_json::Value` (which has no
|
||||
//! uniffi representation) in the middle of it.
|
||||
//!
|
||||
//! The cost of mirroring is drift — an Android client quietly missing a field the
|
||||
//! desktop gained. Every conversion below therefore DESTRUCTURES the core struct
|
||||
//! exhaustively instead of reading fields it cares about. Add a field to
|
||||
//! `core::local::models::Note` and this file stops compiling until Android is told
|
||||
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
|
||||
//! style here; please keep it.
|
||||
|
||||
use thoughtsync_core::local::models as core_models;
|
||||
use thoughtsync_core::sync::client as core_client;
|
||||
use thoughtsync_core::sync::compat as core_compat;
|
||||
use thoughtsync_core::sync::engine as core_engine;
|
||||
use thoughtsync_core::sync::pull as core_pull;
|
||||
use thoughtsync_core::sync::push as core_push;
|
||||
use thoughtsync_core::sync::state as core_state;
|
||||
|
||||
/// A note, with everything needed to render a card or open the editor.
|
||||
///
|
||||
/// Timestamps are RFC3339 strings, not a date type: that is what SQLite holds and
|
||||
/// what the server speaks, and converting here would mean this layer picking a
|
||||
/// calendar/timezone policy that belongs to the UI.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
/// Always present. Derived by the core, never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
pub deleted_at: Option<String>,
|
||||
pub remind_at: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub labels: Vec<NoteLabel>,
|
||||
pub items: Vec<ChecklistItem>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub previews: Vec<LinkPreview>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// An Android build the linked server is offering, already judged to be newer.
|
||||
///
|
||||
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||
/// reason every other record here is one: the core's shapes are contracted with
|
||||
/// other consumers, and `url` in particular is an implementation detail of how
|
||||
/// the download is fetched — the app never needs it, because it asks the core to
|
||||
/// do the downloading.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ClientUpdate {
|
||||
/// For people to read.
|
||||
pub version: String,
|
||||
/// For machines to compare.
|
||||
pub version_code: i64,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
|
||||
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
|
||||
// Destructured exhaustively, like every other conversion in this file: a
|
||||
// field added upstream stops this compiling until Android is told what to
|
||||
// do with it, which turns silent drift into a build error.
|
||||
let thoughtsync_core::sync::client::ClientRelease {
|
||||
version,
|
||||
version_code,
|
||||
size,
|
||||
sha256: _,
|
||||
url: _,
|
||||
} = r;
|
||||
ClientUpdate {
|
||||
version,
|
||||
version_code,
|
||||
size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
/// True when attached because of a `#tag` in the body, so the UI can show it is
|
||||
/// owned by the text and not independently removable.
|
||||
pub via_tag: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ChecklistItem {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: Option<String>,
|
||||
pub mime: String,
|
||||
pub size: Option<i64>,
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct LinkPreview {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
|
||||
impl From<core_models::Note> for Note {
|
||||
fn from(value: core_models::Note) -> Self {
|
||||
// Exhaustive on purpose — see the module header.
|
||||
let core_models::Note {
|
||||
id,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
trashed,
|
||||
deleted_at,
|
||||
remind_at,
|
||||
recurrence,
|
||||
labels,
|
||||
items,
|
||||
attachments,
|
||||
previews,
|
||||
created_at,
|
||||
updated_at,
|
||||
} = value;
|
||||
Note {
|
||||
id,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
trashed,
|
||||
deleted_at,
|
||||
remind_at,
|
||||
recurrence,
|
||||
labels: labels.into_iter().map(NoteLabel::from).collect(),
|
||||
items: items.into_iter().map(ChecklistItem::from).collect(),
|
||||
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||
previews: previews.into_iter().map(LinkPreview::from).collect(),
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::NoteLabel> for NoteLabel {
|
||||
fn from(value: core_models::NoteLabel) -> Self {
|
||||
let core_models::NoteLabel {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
via_tag,
|
||||
} = value;
|
||||
NoteLabel {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
via_tag,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::ChecklistItem> for ChecklistItem {
|
||||
fn from(value: core_models::ChecklistItem) -> Self {
|
||||
let core_models::ChecklistItem {
|
||||
id,
|
||||
text,
|
||||
checked,
|
||||
position,
|
||||
} = value;
|
||||
ChecklistItem {
|
||||
id,
|
||||
text,
|
||||
checked,
|
||||
position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::Attachment> for Attachment {
|
||||
fn from(value: core_models::Attachment) -> Self {
|
||||
let core_models::Attachment {
|
||||
id,
|
||||
url,
|
||||
filename,
|
||||
mime,
|
||||
size,
|
||||
sha256,
|
||||
} = value;
|
||||
Attachment {
|
||||
id,
|
||||
url,
|
||||
filename,
|
||||
mime,
|
||||
size,
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_models::LinkPreview> for LinkPreview {
|
||||
fn from(value: core_models::LinkPreview) -> Self {
|
||||
let core_models::LinkPreview {
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image_url,
|
||||
site_name,
|
||||
} = value;
|
||||
LinkPreview {
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image_url,
|
||||
site_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A label, as the sidebar lists them.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Label {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Same colour vocabulary as notes, so one palette serves both.
|
||||
pub color: String,
|
||||
/// How many notes carry it. Only populated in listings — `None` elsewhere,
|
||||
/// matching the REST single-label responses.
|
||||
pub count: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<core_models::Label> for Label {
|
||||
fn from(value: core_models::Label) -> Self {
|
||||
let core_models::Label {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
count,
|
||||
} = value;
|
||||
Label {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── queries and edits ─────────────────────────────
|
||||
|
||||
/// What the board is asking for. Mirrors the core's `ListQuery`.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteQuery {
|
||||
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
|
||||
pub view: String,
|
||||
pub label_id: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub facets: Option<NoteFacets>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteFacets {
|
||||
pub q: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub has_reminder: Option<bool>,
|
||||
pub has_attachment: Option<bool>,
|
||||
pub created_after: Option<String>,
|
||||
pub created_before: Option<String>,
|
||||
}
|
||||
|
||||
impl From<NoteQuery> for core_models::ListQuery {
|
||||
fn from(value: NoteQuery) -> Self {
|
||||
let NoteQuery {
|
||||
view,
|
||||
label_id,
|
||||
sort,
|
||||
facets,
|
||||
} = value;
|
||||
core_models::ListQuery {
|
||||
view,
|
||||
label_id,
|
||||
sort,
|
||||
facets: facets.map(core_models::Facets::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NoteFacets> for core_models::Facets {
|
||||
fn from(value: NoteFacets) -> Self {
|
||||
let NoteFacets {
|
||||
q,
|
||||
color,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
} = value;
|
||||
core_models::Facets {
|
||||
q,
|
||||
color,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
created_after,
|
||||
created_before,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new note.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteDraft {
|
||||
pub body: String,
|
||||
/// "default" unless the user picked a colour.
|
||||
pub color: String,
|
||||
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
||||
/// is not an alternative to `body` — it is an addition to it.
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
fn from(value: NoteDraft) -> Self {
|
||||
let NoteDraft { body, color, items } = value;
|
||||
core_models::NoteCreateInput { body, color, items }
|
||||
}
|
||||
}
|
||||
|
||||
/// One field-level change to a note.
|
||||
///
|
||||
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
||||
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
||||
/// null — and Kotlin has no way to express the third with a nullable field.
|
||||
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
|
||||
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
|
||||
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum NoteEdit {
|
||||
Body { value: String },
|
||||
Color { value: String },
|
||||
Pinned { value: bool },
|
||||
Archived { value: bool },
|
||||
RemindAt { value: String },
|
||||
ClearRemindAt,
|
||||
Recurrence { value: String },
|
||||
ClearRecurrence,
|
||||
}
|
||||
|
||||
impl NoteEdit {
|
||||
/// The (key, value) pair this edit contributes to the core's JSON patch.
|
||||
///
|
||||
/// The core reads a patch object where a present key means "change this" and a
|
||||
/// null value means "clear it" — the shape the REST API and the Tauri commands
|
||||
/// both already speak. Translating here keeps that one patch format in one
|
||||
/// place instead of teaching a second dialect to the store.
|
||||
fn entry(self) -> (&'static str, serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match self {
|
||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||
NoteEdit::Color { value } => ("color", Value::String(value)),
|
||||
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||
NoteEdit::ClearRemindAt => ("remind_at", Value::Null),
|
||||
NoteEdit::Recurrence { value } => ("recurrence", Value::String(value)),
|
||||
NoteEdit::ClearRecurrence => ("recurrence", Value::Null),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a list of edits into the single patch object the store applies.
|
||||
///
|
||||
/// Later edits win on a repeated key, which is what a caller batching "set a
|
||||
/// reminder, then clear it" would expect.
|
||||
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
||||
let mut map = serde_json::Map::new();
|
||||
for edit in edits {
|
||||
let (key, value) = edit.entry();
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── sync ─────────────────────────────────
|
||||
|
||||
/// What the UI may know about the link. Carries no device token, deliberately —
|
||||
/// the core withholds it from `Status` for the same reason, and a bearer token has
|
||||
/// no business in UI state.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SyncStatus {
|
||||
pub linked: bool,
|
||||
pub server_url: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
pub last_sync_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<core_state::Status> for SyncStatus {
|
||||
fn from(value: core_state::Status) -> Self {
|
||||
let core_state::Status {
|
||||
linked,
|
||||
server_url,
|
||||
last_cursor,
|
||||
last_sync_at,
|
||||
} = value;
|
||||
SyncStatus {
|
||||
linked,
|
||||
server_url,
|
||||
last_cursor,
|
||||
last_sync_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a server said about itself, before committing to anything.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ProbeResult {
|
||||
/// Normalised by the core — this, not what the user typed, is what gets stored.
|
||||
pub base_url: String,
|
||||
pub site_name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub trash_retention_days: Option<u32>,
|
||||
pub compatibility: Compatibility,
|
||||
}
|
||||
|
||||
/// Whether this client and that server can sync at all.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum Compatibility {
|
||||
Ok,
|
||||
/// Safe to sync, but these named capabilities are missing. The UI should say so
|
||||
/// rather than let a feature silently do nothing.
|
||||
Degraded {
|
||||
unavailable: Vec<String>,
|
||||
},
|
||||
/// Do not sync. `client_must_update` says which side can fix it, so the message
|
||||
/// can be actionable.
|
||||
Incompatible {
|
||||
reason: String,
|
||||
client_must_update: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<core_compat::Compatibility> for Compatibility {
|
||||
fn from(value: core_compat::Compatibility) -> Self {
|
||||
match value {
|
||||
core_compat::Compatibility::Ok => Compatibility::Ok,
|
||||
core_compat::Compatibility::Degraded { unavailable } => {
|
||||
Compatibility::Degraded { unavailable }
|
||||
}
|
||||
core_compat::Compatibility::Incompatible {
|
||||
reason,
|
||||
client_must_update,
|
||||
} => Compatibility::Incompatible {
|
||||
reason,
|
||||
client_must_update,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_client::ProbeResult> for ProbeResult {
|
||||
fn from(value: core_client::ProbeResult) -> Self {
|
||||
let core_client::ProbeResult {
|
||||
base_url,
|
||||
server,
|
||||
compatibility,
|
||||
} = value;
|
||||
let core_compat::ServerInfo {
|
||||
site_name,
|
||||
version,
|
||||
// Protocol numbers are the raw material of the compatibility verdict,
|
||||
// which is already carried above in a form the UI can act on. Sending
|
||||
// them too would invite a second, worse judgement being made in Kotlin.
|
||||
sync_protocol_version: _,
|
||||
min_client_protocol_version: _,
|
||||
sync_features: _,
|
||||
trash_retention_days,
|
||||
} = server;
|
||||
ProbeResult {
|
||||
base_url,
|
||||
site_name,
|
||||
version,
|
||||
trash_retention_days,
|
||||
compatibility: compatibility.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who the server thinks this device belongs to.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl From<core_client::Identity> for Identity {
|
||||
fn from(value: core_client::Identity) -> Self {
|
||||
let core_client::Identity {
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
} = value;
|
||||
Identity {
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of this device's token on the server during an unlink.
|
||||
///
|
||||
/// Separate from the local result because the local half always succeeds and the
|
||||
/// remote half may not — someone unlinking a machine they are selling deserves to be
|
||||
/// told plainly that the token is still live.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum RevokeOutcome {
|
||||
Revoked,
|
||||
/// This server predates the self-revoke route. Only the web app can retire it.
|
||||
Unsupported,
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
/// Nothing to revoke; the app wasn't linked.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl From<core_client::RevokeOutcome> for RevokeOutcome {
|
||||
fn from(value: core_client::RevokeOutcome) -> Self {
|
||||
match value {
|
||||
core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked,
|
||||
core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported,
|
||||
core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason },
|
||||
core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of one full push-then-pull cycle.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SyncOutcome {
|
||||
pub push: PushSummary,
|
||||
pub pull: PullSummary,
|
||||
/// The state after the cycle, so the UI refreshes from one call rather than
|
||||
/// following every sync with a status query.
|
||||
pub status: SyncStatus,
|
||||
}
|
||||
|
||||
/// Counts are `u64` because the core uses `usize`, which has no uniffi
|
||||
/// representation. Widening is lossless on every target we build for; narrowing to
|
||||
/// u32 would be a silent truncation waiting for a very large sync.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct PushSummary {
|
||||
pub batches: u64,
|
||||
pub sent: u64,
|
||||
pub created: u64,
|
||||
pub applied: u64,
|
||||
/// The server had a newer edit and kept it. Not a failure — the local row stops
|
||||
/// being dirty and the following pull adopts the server's version.
|
||||
pub kept: u64,
|
||||
pub noop: u64,
|
||||
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
|
||||
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||
pub rejected: u64,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct PullSummary {
|
||||
pub pages: u64,
|
||||
pub notes_applied: u64,
|
||||
pub notes_deleted: u64,
|
||||
pub labels_applied: u64,
|
||||
pub labels_deleted: u64,
|
||||
pub cursor: i64,
|
||||
/// Rows that still held unpushed local edits when the server's version landed on
|
||||
/// top. Should be 0 in a normal cycle, because push runs first; anything higher
|
||||
/// means local work was overwritten, which is worth saying out loud.
|
||||
pub clobbered_dirty: u64,
|
||||
pub blobs_downloaded: u64,
|
||||
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
|
||||
/// rather than fatal.
|
||||
pub blobs_failed: u64,
|
||||
}
|
||||
|
||||
impl From<core_push::PushSummary> for PushSummary {
|
||||
fn from(value: core_push::PushSummary) -> Self {
|
||||
let core_push::PushSummary {
|
||||
batches,
|
||||
sent,
|
||||
created,
|
||||
applied,
|
||||
kept,
|
||||
noop,
|
||||
rejected,
|
||||
errors,
|
||||
} = value;
|
||||
PushSummary {
|
||||
batches: batches as u64,
|
||||
sent: sent as u64,
|
||||
created: created as u64,
|
||||
applied: applied as u64,
|
||||
kept: kept as u64,
|
||||
noop: noop as u64,
|
||||
rejected: rejected as u64,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_pull::PullSummary> for PullSummary {
|
||||
fn from(value: core_pull::PullSummary) -> Self {
|
||||
let core_pull::PullSummary {
|
||||
pages,
|
||||
notes_applied,
|
||||
notes_deleted,
|
||||
labels_applied,
|
||||
labels_deleted,
|
||||
cursor,
|
||||
clobbered_dirty,
|
||||
blobs_downloaded,
|
||||
blobs_failed,
|
||||
} = value;
|
||||
PullSummary {
|
||||
pages: pages as u64,
|
||||
notes_applied: notes_applied as u64,
|
||||
notes_deleted: notes_deleted as u64,
|
||||
labels_applied: labels_applied as u64,
|
||||
labels_deleted: labels_deleted as u64,
|
||||
cursor,
|
||||
clobbered_dirty: clobbered_dirty as u64,
|
||||
blobs_downloaded: blobs_downloaded as u64,
|
||||
blobs_failed: blobs_failed as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<core_engine::SyncOutcome> for SyncOutcome {
|
||||
fn from(value: core_engine::SyncOutcome) -> Self {
|
||||
let core_engine::SyncOutcome { push, pull, status } = value;
|
||||
SyncOutcome {
|
||||
push: push.into(),
|
||||
pull: pull.into(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||
let set = patch_from(vec![NoteEdit::RemindAt {
|
||||
value: "2026-01-01T00:00:00Z".to_string(),
|
||||
}]);
|
||||
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
|
||||
|
||||
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
|
||||
assert!(
|
||||
cleared["remind_at"].is_null(),
|
||||
"a clear must reach the store as JSON null — an absent key means \
|
||||
'leave alone', which is a different instruction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_edit_list_is_an_empty_patch() {
|
||||
// Not merely tidy: the core rejects a non-object patch, and a UI that
|
||||
// batches edits may well end up sending none.
|
||||
assert_eq!(patch_from(vec![]), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_edits_win_on_a_repeated_field() {
|
||||
let patch = patch_from(vec![
|
||||
NoteEdit::RemindAt {
|
||||
value: "2026-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
NoteEdit::ClearRemindAt,
|
||||
]);
|
||||
assert!(patch["remind_at"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Where the generated Kotlin lands. Matches the app's package so the bindings are
|
||||
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
|
||||
[bindings.kotlin]
|
||||
package_name = "com.fabledsword.thoughtsync.core"
|
||||
cdylib_name = "thoughtsync_ffi"
|
||||
@@ -0,0 +1,16 @@
|
||||
# --enable-native-access=ALL-UNNAMED silences the JDK 22+ "restricted method in
|
||||
# java.lang.System has been called" warning that Gradle 9.1's bundled
|
||||
# native-platform jar trips via System.load(). Same opt-in Minstrel needs on the
|
||||
# same Gradle/JDK pair; future JDKs promote the warning to an error.
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# Matches Minstrel: detekt 2.0-alpha and the ktlint Gradle plugin still have
|
||||
# intermittent configuration-cache holes. Warn rather than fail so the CC speedup
|
||||
# applies where it can.
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,57 @@
|
||||
[versions]
|
||||
# Pinned as a MATRIX, matching Minstrel's proven combination on the same JDK:
|
||||
# - Gradle 9.1.0 supports JDK 25 (see gradle-wrapper.properties)
|
||||
# - AGP 9.0.1 requires Gradle 9.1.0+
|
||||
# - Kotlin 2.3.x is AGP 9's built-in Kotlin path
|
||||
# ci-rust-android ships JDK 25, so the wrapper floor is load-bearing: an older
|
||||
# Gradle fails on that JDK with an opaque "25.0.3" message.
|
||||
agp = "9.0.1"
|
||||
kotlin = "2.3.21"
|
||||
compose-bom = "2026.05.01"
|
||||
lifecycle = "2.8.7"
|
||||
activity-compose = "1.9.3"
|
||||
coroutines = "1.9.0"
|
||||
|
||||
# WorkManager runs the background sync. `work-runtime-ktx` is NOT used: as of
|
||||
# 2.11 it is a 6 KB stub and every Kotlin extension (`PeriodicWorkRequestBuilder`,
|
||||
# `CoroutineWorker`) has moved into `work-runtime` itself. Verified by unpacking
|
||||
# both artifacts, not from memory.
|
||||
work = "2.11.2"
|
||||
|
||||
# ktlint and detekt are NOT Gradle plugins here. ci-rust-android already ships
|
||||
# both as pinned CLIs (M12 step 3), and the CI lane invokes those directly. Adding
|
||||
# the Gradle plugins would mean a SECOND pinned version of each tool, resolved at
|
||||
# build time, that has to be kept in lockstep with the image's by hand — and the
|
||||
# first attempt at it failed outright, because the detekt version Minstrel pins
|
||||
# (2.0.0-alpha.3) is not published to Maven Central or the plugin portal at all.
|
||||
|
||||
# JNA is not optional: uniffi's Kotlin bindings call into the .so through it.
|
||||
# The @aar classifier matters — the plain jar has no Android native payload and
|
||||
# fails at runtime with UnsatisfiedLinkError rather than at build time.
|
||||
jna = "5.14.0"
|
||||
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
# Icons only from -core, deliberately: it carries the common set (Menu, Search,
|
||||
# Close, Add) and is already on the material3 path. -extended adds ~1,000 vectors
|
||||
# for the handful the drawer would use.
|
||||
compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "work" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "ThoughtSync"
|
||||
|
||||
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
|
||||
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
|
||||
// invoking cargo-ndk (see app/build.gradle.kts), not by building it.
|
||||
include(":app")
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check every R.string / R.plurals reference against strings.xml.
|
||||
|
||||
Three ways a resource reference compiles and then fails, none of which ktlint,
|
||||
detekt or the Kotlin compiler will catch:
|
||||
|
||||
1. the name does not exist -> resource-not-found at runtime
|
||||
2. `stringResource` on a plural (or the reverse) -> wrong overload, wrong text
|
||||
3. the format string takes more arguments than the call passes -> the format
|
||||
silently renders `%2$s` as literal text, or throws
|
||||
|
||||
python3 android/tools/check-strings.py
|
||||
|
||||
Exits non-zero on any problem.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
BASE = (
|
||||
sys.argv[1]
|
||||
if len(sys.argv) > 1
|
||||
else os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||
)
|
||||
STRINGS = os.path.join(BASE, "app", "src", "main", "res", "values", "strings.xml")
|
||||
SOURCES = os.path.join(BASE, "app", "src", "main", "java", "**", "*.kt")
|
||||
|
||||
CALL = re.compile(
|
||||
r"(stringResource|pluralStringResource)\(\s*R\.(string|plurals)\.(\w+)"
|
||||
r"((?:[^()]|\([^()]*\))*)\)"
|
||||
)
|
||||
|
||||
|
||||
def arity(text):
|
||||
"""How many distinct arguments a format string consumes."""
|
||||
numbered = set(re.findall(r"%(\d)\$", text))
|
||||
return len(numbered) if numbered else len(re.findall(r"%[sd]", text))
|
||||
|
||||
|
||||
def supplied_args(rest):
|
||||
"""Count top-level commas in an argument tail.
|
||||
|
||||
Kotlin permits a TRAILING comma before the closing paren, which is not an
|
||||
argument — counting it inflated every multi-line call by one the first time
|
||||
this was written, and made three correct call sites look broken. Braces count
|
||||
toward depth as well as parens, or a comma inside a lambda would be read as
|
||||
another argument.
|
||||
"""
|
||||
rest = rest.rstrip()
|
||||
if rest.endswith(","):
|
||||
rest = rest[:-1]
|
||||
depth = 0
|
||||
count = 0
|
||||
for ch in rest:
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
elif ch == "," and depth == 0:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
root = ET.parse(STRINGS).getroot()
|
||||
strings = {e.get("name"): "".join(e.itertext()) for e in root.findall("string")}
|
||||
plurals = {
|
||||
e.get("name"): max(
|
||||
(arity("".join(i.itertext())) for i in e.findall("item")), default=0
|
||||
)
|
||||
for e in root.findall("plurals")
|
||||
}
|
||||
|
||||
problems = 0
|
||||
for path in glob.glob(SOURCES, recursive=True):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
for match in CALL.finditer(src):
|
||||
fn, kind, name, rest = match.groups()
|
||||
line = src[: match.start()].count("\n") + 1
|
||||
where = f"{os.path.basename(path)}:{line} {name}"
|
||||
|
||||
if kind == "string" and name not in strings:
|
||||
print(f"MISSING {where}: no such string")
|
||||
problems += 1
|
||||
continue
|
||||
if kind == "plurals" and name not in plurals:
|
||||
print(f"MISSING {where}: no such plural")
|
||||
problems += 1
|
||||
continue
|
||||
if (fn == "pluralStringResource") != (kind == "plurals"):
|
||||
print(f"KIND {where}: {fn} used on R.{kind}")
|
||||
problems += 1
|
||||
continue
|
||||
|
||||
passed = supplied_args(rest)
|
||||
# A plural call passes the count first, then the format arguments.
|
||||
wanted = arity(strings[name]) if kind == "string" else plurals[name] + 1
|
||||
if passed != wanted:
|
||||
print(f"ARITY {where}: wants {wanted}, call passes {passed}")
|
||||
problems += 1
|
||||
|
||||
print(f"\n{len(strings)} strings, {len(plurals)} plurals, {problems} problems")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Flag capitalised identifiers that are neither imported nor declared locally.
|
||||
|
||||
This exists because ktlint and detekt are both structurally blind to it: they
|
||||
parse Kotlin without resolving symbols, so a *missing import* is invisible to
|
||||
them and both pass a file that cannot compile. The first sync-screen push failed
|
||||
in CI on exactly that (`Unresolved reference 'Build'` — `android.os.Build` was
|
||||
lost in a file split), after a clean local analyzer run.
|
||||
|
||||
Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real
|
||||
one; this is a cheap pre-push filter for the single mistake that survives every
|
||||
other local gate. It errs toward false positives — anything it cannot account
|
||||
for is reported rather than assumed fine.
|
||||
|
||||
It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()`
|
||||
where `Foo` is an object declared here. That case was added after moving a
|
||||
function between two objects and forgetting to paste it into the second: the
|
||||
call site read `Other.thing()`, resolved fine as far as the leading token, and
|
||||
failed in CI (785ebdb).
|
||||
|
||||
Still NOT caught, so a clean run is not over-read: members of anything declared
|
||||
outside this package, members reached through a variable rather than a type
|
||||
name, and every question about types. Those are what `compileDebugKotlin` is for.
|
||||
|
||||
python3 android/tools/check-symbols.py [source-root]
|
||||
|
||||
Exits non-zero when something is unaccounted for.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
DEFAULT_ROOT = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "app", "src", "main", "java"
|
||||
)
|
||||
|
||||
# Available without an import: kotlin.* and kotlin.collections.*, plus
|
||||
# java.lang.* which Kotlin/JVM also imports by default.
|
||||
IMPLICIT = set(
|
||||
"""
|
||||
String Int Long Short Byte Boolean Char Float Double Unit Any Nothing Number
|
||||
UInt ULong UShort UByte Array List Set Map MutableList MutableSet MutableMap
|
||||
Collection Iterable Iterator Sequence Pair Triple Comparable Comparator
|
||||
Throwable Exception RuntimeException IllegalArgumentException IllegalStateException
|
||||
Error Result Regex StringBuilder CharSequence Enum Annotation Function
|
||||
Deprecated Suppress OptIn JvmStatic JvmField JvmName JvmOverloads Volatile
|
||||
Synchronized Throws Target Retention Repeatable MustBeDocumented
|
||||
System Math Object Class Thread Runnable Void Integer Character
|
||||
StringBuffer
|
||||
""".split()
|
||||
)
|
||||
|
||||
# `R` is generated at build time and never imported from the app's own package.
|
||||
GENERATED = {"R"}
|
||||
|
||||
DECL = re.compile(
|
||||
r"^\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
|
||||
r"(?:expect |actual |external |abstract |final |open |sealed |data |value |"
|
||||
r"inline |enum |annotation |fun |companion |const |lateinit )*"
|
||||
r"(?:class|interface|object|typealias|fun|val|var)\s+"
|
||||
r"(?:<[^>]*>\s*)?([A-Za-z_]\w*)",
|
||||
re.M,
|
||||
)
|
||||
|
||||
|
||||
def strip(src: str) -> str:
|
||||
"""Blank out comments and string literals.
|
||||
|
||||
Order matters: raw strings before block comments, and line comments must NOT
|
||||
use DOTALL — `//.*` with re.S eats from the first comment to end of file,
|
||||
which silently empties the input and makes the whole check pass vacuously.
|
||||
"""
|
||||
src = re.sub(r'"""(?:.|\n)*?"""', '""', src)
|
||||
src = re.sub(r"/\*(?:.|\n)*?\*/", " ", src)
|
||||
src = re.sub(r"//[^\n]*", " ", src)
|
||||
src = re.sub(r'"(?:\\.|[^"\\\n])*"', '""', src)
|
||||
return src
|
||||
|
||||
|
||||
def object_members(src: str) -> dict:
|
||||
"""Map each `object Foo` declared here to the names declared directly in it.
|
||||
|
||||
Brace-counted rather than regex-matched: an object body contains nested
|
||||
braces (lambdas, apply blocks, companions) and no regex closes correctly over
|
||||
them. Only top-level members count — anything nested deeper is not reachable
|
||||
as `Foo.member` anyway.
|
||||
"""
|
||||
members = {}
|
||||
for match in re.finditer(r"^(?:internal |private )?object (\w+)\s*\{", src, re.M):
|
||||
name = match.group(1)
|
||||
depth = 0
|
||||
body_start = match.end() - 1
|
||||
for i in range(body_start, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
body = src[body_start + 1 : i]
|
||||
own = set()
|
||||
depth = 0
|
||||
for line in body.splitlines():
|
||||
if depth == 0:
|
||||
# Nested TYPES count as members too: `Foo.Bar` where Bar is a
|
||||
# data class inside object Foo is an ordinary reference, and
|
||||
# leaving them out made the checker report four false positives
|
||||
# the first time an object held one.
|
||||
decl = re.match(
|
||||
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
|
||||
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
|
||||
r"(?:fun|val|var|class|object|interface)\s+"
|
||||
r"(?:<[^>]*>\s*)?(\w+)",
|
||||
line,
|
||||
)
|
||||
if decl:
|
||||
own.add(decl.group(1))
|
||||
depth += line.count("{") - line.count("}")
|
||||
members[name] = own
|
||||
return members
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ROOT
|
||||
files = [
|
||||
os.path.join(d, n)
|
||||
for d, _, names in os.walk(root)
|
||||
for n in names
|
||||
if n.endswith(".kt")
|
||||
]
|
||||
|
||||
declared = collections.defaultdict(set)
|
||||
objects = {}
|
||||
parsed = {}
|
||||
for path in files:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
raw = fh.read()
|
||||
package = re.search(r"^package\s+([\w.]+)", raw, re.M).group(1)
|
||||
src = strip(raw)
|
||||
parsed[path] = (package, src, raw)
|
||||
objects.update(object_members(src))
|
||||
for match in DECL.finditer(src):
|
||||
declared[package].add(match.group(1))
|
||||
# Enum entries are declarations too; DECL only sees the class itself.
|
||||
for match in re.finditer(r"enum class \w+[^{]*\{([^};]*)", src):
|
||||
for entry in match.group(1).split(","):
|
||||
name = entry.strip().split("(")[0].strip()
|
||||
if re.fullmatch(r"[A-Z]\w*", name):
|
||||
declared[package].add(name)
|
||||
|
||||
problems = 0
|
||||
for path in sorted(files):
|
||||
package, src, raw = parsed[path]
|
||||
imported = set()
|
||||
for match in re.finditer(r"^import\s+([\w.]+)(?:\s+as\s+(\w+))?", raw, re.M):
|
||||
imported.add(match.group(2) or match.group(1).split(".")[-1])
|
||||
# Type parameters are declared inline at their use site.
|
||||
type_params = set()
|
||||
for match in re.finditer(r"(?:fun|class|interface)\s*<([^>]*)>", src):
|
||||
type_params |= set(
|
||||
re.findall(r"\b([A-Z]\w*)\b(?=\s*(?::|,|$))", match.group(1))
|
||||
)
|
||||
known = imported | declared[package] | IMPLICIT | GENERATED | type_params
|
||||
|
||||
# Capitalised tokens NOT preceded by a dot: `Icons.Filled` resolves
|
||||
# through `Icons`, so only the leading segment needs to be accounted for.
|
||||
for match in re.finditer(r"(?<![\w.])@?([A-Z][A-Za-z0-9_]*)\b", src):
|
||||
name = match.group(1)
|
||||
if name in known:
|
||||
continue
|
||||
line = src[: match.start()].count("\n") + 1
|
||||
print(f"{os.path.relpath(path, root)}:{line}: unresolved '{name}'")
|
||||
problems += 1
|
||||
|
||||
# Members of objects declared in this package.
|
||||
for match in re.finditer(r"(?<![\w.])([A-Z][A-Za-z0-9_]*)\.(\w+)", src):
|
||||
owner, member = match.group(1), match.group(2)
|
||||
if owner not in objects or member in objects[owner]:
|
||||
continue
|
||||
line = src[: match.start()].count("\n") + 1
|
||||
print(
|
||||
f"{os.path.relpath(path, root)}:{line}: "
|
||||
f"'{owner}' has no member '{member}'"
|
||||
)
|
||||
problems += 1
|
||||
|
||||
print(f"\n{len(files)} files, {problems} unresolved")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+320
-15
@@ -23,7 +23,7 @@ build (docker buildx).
|
||||
- ruff — lint job runs `ruff check src/` with zero install overhead
|
||||
- uv — test job creates the venv (`uv venv /opt/venv`) and installs the package
|
||||
with dev deps
|
||||
- docker CLI + buildx — build job pushes the dev/release image to the Forgejo
|
||||
- docker CLI + buildx — build job pushes the dev/release image to the Fabled-Git
|
||||
registry
|
||||
|
||||
## Per-job tool installs
|
||||
@@ -45,6 +45,70 @@ entirely on `ci-python:3.14`.
|
||||
(family rule 46).
|
||||
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
|
||||
representative of the deployed image.
|
||||
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.**
|
||||
```yaml
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
```
|
||||
Upstream's `actions/upload-artifact@v4` cannot work against this instance and
|
||||
no server-side change will help: its `isGhes()` rejects any hostname that isn't
|
||||
`github.com` / `*.ghe.com` / `*.localhost` and throws before it opens a
|
||||
connection, so the server is never asked what it supports. `@v3` is worse — it
|
||||
reports success, and Gitea then serves artifacts back only through the v4 API
|
||||
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||
to every retrieval path. A green job producing nothing retrievable.
|
||||
|
||||
`bvandeusen/upload-artifact` is our pull mirror of `forgejo/upload-artifact`
|
||||
(the Forgejo project's fork, one commit on upstream v5.0.0 disabling that
|
||||
check). Mirrored so CI depends on a commit we hold; pinned by SHA because the
|
||||
mirror auto-syncs and a moved upstream tag would otherwise change what runs.
|
||||
|
||||
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
|
||||
`continue-on-error`. They previously had both defaults inverted, which is how
|
||||
110 unreachable artifacts accumulated on this repo without anyone noticing —
|
||||
the upload could fail or match nothing and the run still went green. Scribe
|
||||
issues 2255 / 2270 have the full teardown.
|
||||
|
||||
Download: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||
for the id (global run id, not the repo-scoped run number), then
|
||||
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
|
||||
`python3 -m zipfile -e`.
|
||||
|
||||
## The integration lane
|
||||
|
||||
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the
|
||||
operator's container started — 26 revisions, none of them ever executed by CI — and
|
||||
the schema the migrations build had never been checked against the models that read
|
||||
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
|
||||
watching.
|
||||
|
||||
Copied from FabledScribe's `integration` job, which had already solved the awkward
|
||||
parts. Three of them are family rules for a reason:
|
||||
|
||||
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service
|
||||
container's name from the truncated job DISPLAY name, and the discovery step filters
|
||||
`docker ps` by it. A spaced or underscored name breaks the filter.
|
||||
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
||||
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
||||
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
|
||||
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
||||
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
||||
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||
|
||||
`postgres:16-alpine`, matching the production compose, so the schema is proven against
|
||||
the Postgres it will actually meet. The schema comes from **real migrations, never
|
||||
`metadata.create_all`** (rule 82): testing a schema no deployment has ever seen proves
|
||||
nothing, and that `alembic upgrade head` step IS the migration test — a broken revision
|
||||
fails the job there, before it can fail a container start.
|
||||
|
||||
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
|
||||
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
|
||||
BEFORE each test rather than after, so a failure leaves its rows behind to look at.
|
||||
|
||||
Like `test`, it runs for visibility and does **not** gate the build.
|
||||
|
||||
There is no local way to run it — that would mean standing up Postgres on the
|
||||
workstation, which rule 12 reserves for an explicit request. This lane is verified in
|
||||
CI.
|
||||
|
||||
## Desktop (Tauri) lane — separate workflow
|
||||
|
||||
@@ -58,9 +122,17 @@ backend/frontend push.
|
||||
`container.image`; `runs-on: python-ci` is only a scheduling label.
|
||||
- **Steps:** build the shared frontend (embedded by `generate_context!`) →
|
||||
`cargo tauri icon app-icon.png` (platform icon set from the committed 1024px
|
||||
source) → `cargo fmt --check` → `cargo clippy -D warnings` → `cargo test` →
|
||||
`cargo tauri build` (produces `.deb` + `.AppImage`) → de-bundle the AppImage's
|
||||
graphics libs → verify the `.deb` → repackage for pacman.
|
||||
source) → `cargo clippy --workspace -D warnings` → `cargo test --workspace` →
|
||||
`cargo fmt --all --check` → `cargo tauri build` (produces `.deb` + `.AppImage`)
|
||||
→ de-bundle the AppImage's graphics libs → verify the `.deb` → repackage for
|
||||
pacman.
|
||||
- **The three analyzer steps run from the REPO ROOT with `--workspace`**, not
|
||||
from `desktop/src-tauri`. Scoping them to the desktop package was correct while
|
||||
it was the only Rust here; after the core was extracted it silently stopped
|
||||
being — the core's 89 tests stopped running, and a fourth crate would not be
|
||||
linted at all. The dependency crates still COMPILE either way, which is exactly
|
||||
why the gap is invisible from a green run. If you add a workspace member, check
|
||||
that it appears in the `cargo test` output before believing the lane covers it.
|
||||
- **`APPIMAGE_EXTRACT_AND_RUN=1`** is set: AppImage tooling FUSE-mounts by default
|
||||
and CI containers have no `/dev/fuse`.
|
||||
- **Packaging tools used from the image** (none installed at job time, rule 5):
|
||||
@@ -132,22 +204,255 @@ backend/frontend push.
|
||||
- No Postgres lane (unchanged): the desktop app's local store + sync behavior is
|
||||
verified on the operator's machine, not in CI.
|
||||
|
||||
## Formatting the Rust lane before pushing
|
||||
## Android lane — being rebuilt (M12)
|
||||
|
||||
`cargo fmt --check` runs in CI and had failed on four consecutive desktop pushes
|
||||
by itself, each costing a full cycle to learn a whitespace nit. There is no Rust
|
||||
toolchain on the workstation (rule 10), but the CI image is pullable, and running
|
||||
a formatter is neither a test run nor a local stack:
|
||||
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
|
||||
over the shared `thoughtsync-core` crate instead — see Scribe note 2730 for the
|
||||
decision and milestone M12 for the arc.
|
||||
|
||||
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
|
||||
from `ci-tauri-android` rather than deleted (CI-runner `dc802f2`, Scribe #2732).
|
||||
`tauri-cli` is out and `cargo-ndk` is in; the NDK binutils symlinks and the PATH
|
||||
append stayed, because they were never Tauri problems — NDK r23 removed the
|
||||
triple-prefixed binutils that autotools, and so vendored OpenSSL, invokes by bare
|
||||
name. It also carries `ktlint` + `detekt` so the Kotlin analyzer lane needs no
|
||||
second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper —
|
||||
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
|
||||
|
||||
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
|
||||
`thoughtsync-core` from one workspace `Cargo.lock` under `--locked`, so a
|
||||
mismatched Rust minor across the lanes would mean divergent resolution for no
|
||||
reason. Bump the three together or not at all.
|
||||
|
||||
## Checking the Kotlin lane before pushing
|
||||
|
||||
Same authorisation and same reasoning as the Rust section below — analyzers, run
|
||||
in the CI image, with the workflow's exact arguments. From `android/`:
|
||||
|
||||
```
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
||||
-v "$PWD/desktop/src-tauri:/w" -w /w \
|
||||
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fmt --check
|
||||
IMG=git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||
DOCK="docker run --rm --user $(id -u):$(id -g) -e HOME=/tmp -v $PWD:/w -w /w"
|
||||
|
||||
$DOCK $IMG ktlint "app/src/main/**/*.kt"
|
||||
$DOCK $IMG detekt --build-upon-default-config --config config/detekt.yml \
|
||||
--input app/src/main/java
|
||||
```
|
||||
|
||||
Drop `--check` to apply. `--user` keeps the container from leaving root-owned
|
||||
files behind; `CARGO_HOME` points somewhere writable for that user.
|
||||
`HOME=/tmp` because both tools want a writable home for their caches and
|
||||
`--user` has taken the image's away.
|
||||
|
||||
**Neither of these can see a missing import.** They parse Kotlin without
|
||||
resolving symbols, so a file that cannot possibly compile passes both. That is
|
||||
not a gap to work around — it is what these tools are — but it means a clean
|
||||
local run says nothing about whether the code builds. It cost a red CI run on
|
||||
`750d11d`, where `android.os.Build` was lost in a file split and both analyzers
|
||||
were happy.
|
||||
|
||||
So there are two more local checks, each covering one blind spot:
|
||||
|
||||
```
|
||||
python3 android/tools/check-symbols.py
|
||||
python3 android/tools/check-strings.py
|
||||
```
|
||||
|
||||
`check-symbols.py` flags any capitalised identifier that is neither imported,
|
||||
declared in the same package, a type parameter, nor implicitly available — and
|
||||
members of this package's own `object` declarations, so that `Foo.bar()` fails
|
||||
here when `Foo` has no `bar`. That second case exists because moving a function
|
||||
between two objects and forgetting to paste it into the second cost a red run
|
||||
(785ebdb): the call site was correctly qualified and every other gate passed.
|
||||
Not a type checker — `compileDebugKotlin` in CI remains the only real one, and it is
|
||||
also the ONLY lane that type-checks at all, since there is no Android SDK on the
|
||||
workstation.
|
||||
|
||||
`check-strings.py` covers resources, where the compiler is no help either: `R`
|
||||
is generated, so `R.string.whatever` type-checks whether or not the string
|
||||
exists. It catches a missing name, `stringResource` used on a plural or the
|
||||
reverse, and a format string that takes more arguments than the call passes —
|
||||
the last of which renders `%2$s` as literal text rather than failing.
|
||||
|
||||
Run all four before a push that touches Kotlin.
|
||||
|
||||
A caution worth keeping, because it bit twice: a checker of this shape is itself
|
||||
easy to get vacuously right. The first version stripped line comments with
|
||||
`re.sub(r'//.*', src, flags=re.S)`, and DOTALL makes `//.*` swallow each file
|
||||
from its first comment to EOF — so it reported everything clean by examining
|
||||
almost nothing. **Test a checker against a known-bad tree before trusting a
|
||||
green from it**. `check-symbols.py` is verified by deleting the `Build` import
|
||||
from a copy of the source; `check-strings.py` by introducing one of each of its
|
||||
three fault kinds. Its own first version counted Kotlin's trailing commas as
|
||||
arguments and reported three correct call sites as broken — the opposite failure,
|
||||
and the one that teaches you to ignore the tool.
|
||||
|
||||
## A fourth Kotlin check: read the artifact, don't recall the API
|
||||
|
||||
Compose comes from a BOM (`compose-bom` in `libs.versions.toml`), so no file in
|
||||
this repo states which `material3` a build actually gets. Guessing its API and
|
||||
finding out from CI costs eight minutes a try. Resolve and read it instead:
|
||||
|
||||
```
|
||||
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
|
||||
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
|
||||
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
|
||||
|
||||
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
|
||||
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
|
||||
```
|
||||
|
||||
The sources jar answers what javap cannot: default arguments, parameter names,
|
||||
and whether a declaration carries `@ExperimentalMaterial3Api`. That last one is
|
||||
not optional trivia — an unnecessary `@OptIn` is itself a Kotlin warning, so
|
||||
guessing "safely" breaks the build's zero-warning record just as surely as
|
||||
omitting a required one breaks the build.
|
||||
|
||||
Same technique for any dependency. It is how `work-runtime-ktx` was found to be
|
||||
an empty 6 KB stub as of 2.11, with `CoroutineWorker` and
|
||||
`PeriodicWorkRequestBuilder` moved into `work-runtime` itself.
|
||||
|
||||
## Checking the Rust lane before pushing
|
||||
|
||||
There is no Rust toolchain on the workstation (rule 10) and the desktop lane is
|
||||
verified entirely in CI — but the CI image is pullable, so the three analyzer
|
||||
steps can be run against it locally first. **The operator authorised this on
|
||||
2026-08-18** for `fmt`, `clippy` and `test`; it is not licence to run the bundle
|
||||
build or stand up anything.
|
||||
|
||||
Run all three, in this order, before any push that touches Rust:
|
||||
|
||||
```
|
||||
IMG=git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||
DOCK="docker run --rm --user $(id -u):$(id -g) -e CARGO_HOME=/tmp/cargo -v $PWD:/w -w /w"
|
||||
|
||||
$DOCK $IMG cargo fmt --all --check
|
||||
$DOCK $IMG cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
$DOCK $IMG cargo test --locked --workspace
|
||||
```
|
||||
|
||||
Drop `--check` from the first to apply it. `--user` keeps the container from
|
||||
leaving root-owned files behind; `CARGO_HOME` points somewhere writable for that
|
||||
user. Commands are IDENTICAL to the workflow's, deliberately — a local check that
|
||||
differs from CI is worse than none.
|
||||
|
||||
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
|
||||
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
|
||||
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`)
|
||||
matched CI run 3931's byte for byte. Same image, same lockfile, same units.
|
||||
|
||||
`target/` persists on the host between runs, so after the first cold build these
|
||||
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
||||
whenever the space is wanted.
|
||||
|
||||
**Run these on every Rust-touching push, not just the ones that feel risky.** Four
|
||||
consecutive failures across M13's removals — a private `fn` deleted along with the
|
||||
`pub fn` above it, an orphaned `#[serde]` attribute left where a field was removed,
|
||||
and a test pinning a protocol version literal — were all caught by these three
|
||||
commands in under a minute each, after CI had already found them the slow way. A
|
||||
removal is exactly the kind of change that looks safe and isn't: nothing in the
|
||||
Python or TypeScript lanes compiles Rust, so a break can travel several commits
|
||||
before the first lane that does gets to it.
|
||||
|
||||
**Don't infer formatting from existing code.** Several lines in `local/store.rs`
|
||||
exceed 100 characters and survive only because rustfmt cannot break a string
|
||||
literal — copying that shape caused one of the four failures.
|
||||
literal — copying that shape caused one of four consecutive fmt-only CI failures,
|
||||
which is what this whole section exists to prevent.
|
||||
|
||||
## Checking the frontend lane before pushing
|
||||
|
||||
Same technique, same authorisation, same reason — and it covers a gap the Rust gate
|
||||
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE
|
||||
passes the typecheck lane and fails `vite build` in a different workflow. `npm run
|
||||
build` runs both, which is exactly what the desktop lanes run.
|
||||
|
||||
```
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
|
||||
```
|
||||
|
||||
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs
|
||||
already run on, not a separate one. Delete `frontend/node_modules` and `frontend/dist`
|
||||
afterwards; both are gitignored, but neither belongs in a working tree that never
|
||||
builds locally otherwise.
|
||||
|
||||
## The desktop lockfile
|
||||
|
||||
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
|
||||
binary crates. Without it every CI run re-resolved the graph, which meant a
|
||||
released `.deb`/`.AppImage`/`.exe` couldn't be rebuilt from its tag, a build
|
||||
could break with no repo change, and Renovate had nothing to bump (issue 2102).
|
||||
|
||||
Enforced by `--locked` on each job's **first** cargo invocation — `cargo clippy
|
||||
--locked` on Linux, a dedicated `cargo fetch --locked --target
|
||||
x86_64-pc-windows-msvc` step on Windows. If the manifest and the lockfile
|
||||
disagree, the run fails there instead of silently re-resolving; everything after
|
||||
it in the same job then compiles the recorded versions, so the flag isn't
|
||||
repeated on the bundle build. The Windows step exists separately because that
|
||||
job's only crate-graph command is the cross-compile itself, and drift is cheaper
|
||||
to learn in the first thirty seconds than thirty minutes in.
|
||||
|
||||
To regenerate it after a dependency change — same reasoning as `cargo fmt`
|
||||
above, and resolution is neither a test run nor a build:
|
||||
|
||||
```
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/tmp/cargo \
|
||||
-v "$PWD:/w" -w /w \
|
||||
git.fabledsword.com/bvandeusen/ci-tauri:1.97 cargo fetch
|
||||
```
|
||||
|
||||
**`cargo fetch`, not `cargo generate-lockfile`.** Both update the lockfile, but
|
||||
generate-lockfile re-resolves the whole graph from scratch and will happily bump
|
||||
crates that have nothing to do with your change — turning a two-line manifest
|
||||
edit into a few-hundred-line lockfile diff nobody can review. `cargo fetch`
|
||||
performs the minimal resolution: existing pins are preserved, only the new
|
||||
entries are added. Verify it stayed additive before committing (`git diff
|
||||
Cargo.lock | grep '^-'` should show nothing but re-ordered dependency lists).
|
||||
|
||||
Resolving inside the CI image rather than against some other cargo is what keeps
|
||||
the lockfile format and the picked versions identical to what CI would have
|
||||
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
|
||||
manifest change pushed without it fails the gate.
|
||||
|
||||
## Pushing: `dev` is both a branch and a tag
|
||||
|
||||
`git push origin dev` fails in this repo:
|
||||
|
||||
```
|
||||
error: src refspec dev matches more than one
|
||||
```
|
||||
|
||||
The rolling update channel is a release on a **fixed tag named `dev`** (the tag
|
||||
never moves — Fabled-Git has no `/releases/latest/download/<asset>` route, so the
|
||||
updater needs a permanent URL). Once that tag is fetched locally, the short name
|
||||
`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it:
|
||||
|
||||
```
|
||||
git push origin refs/heads/dev:refs/heads/dev
|
||||
```
|
||||
|
||||
## Shell scripts have no CI lane
|
||||
|
||||
Nothing lints `desktop/packaging/*.sh`, and a broken installer or publish script
|
||||
fails at the moment a user runs it, not in a build. Check them before pushing —
|
||||
`install.sh` is POSIX sh, the rest are bash:
|
||||
|
||||
```
|
||||
dash -n desktop/packaging/install.sh # or: sh -n
|
||||
bash -n desktop/packaging/publish-release.sh
|
||||
```
|
||||
|
||||
Where a script resolves URLs from the Fabled-Git API, exercise the resolution
|
||||
against the live instance (plain `curl` reads, no install) rather than trusting
|
||||
the regex by eye. Both channel paths in `install.sh` were verified that way.
|
||||
|
||||
**Hand-assembled JSON: parse it before you push it.** `publish-release.sh` builds
|
||||
its request bodies as shell strings, and quoting context decides what survives
|
||||
into the JSON — a `` \ `` inside an unquoted heredoc loses its backslash to the
|
||||
shell, the same `` \ `` inside a single-quoted variable does not, and reaches
|
||||
Fabled-Git as an illegal escape (HTTP 422, one wasted build). `sh -n` cannot see
|
||||
this. Extract the body block and parse it for every branch it can take:
|
||||
|
||||
```
|
||||
sed -n '/^# The install command printed/,/^JSON$/p' desktop/packaging/publish-release.sh > /tmp/body.sh
|
||||
echo ')' >> /tmp/body.sh
|
||||
bash -c 'GITHUB_SERVER_URL=https://git.fabledsword.com GITHUB_REPOSITORY=o/r \
|
||||
TAG=dev RELEASE_PRERELEASE=true; . /tmp/body.sh; printf "%s" "$BODY" | python3 -m json.tool >/dev/null'
|
||||
```
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CI drops the Android client here on every image build, and the Dockerfile copies
|
||||
the directory into the image (see ci.yml "Fetch the Android client to bake in").
|
||||
|
||||
This file exists so the directory does too. `COPY client/ ...` fails outright on a
|
||||
missing source, which would break every local `docker build` on a tree that has
|
||||
never run that CI step — and an image with no Android client is a supported
|
||||
state, not an error.
|
||||
|
||||
The artifacts themselves are gitignored: a 55 MiB binary does not belong in git
|
||||
history, and it is fetched fresh anyway.
|
||||
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "thoughtsync-core"
|
||||
version = "0.1.0"
|
||||
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
||||
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7).
|
||||
#
|
||||
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
|
||||
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
|
||||
# has to cross-compile on the Windows lane, which is the fragile one. rustls would
|
||||
# instead pull in ring/aws-lc-rs and their assembler. On Linux native-tls uses
|
||||
# OpenSSL, whose headers (libssl-dev) ci-tauri already ships.
|
||||
# default-features off drops http2/charset we don't need for a JSON API.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
# Verifying downloaded attachment bytes against the sha256 the server advertised.
|
||||
sha2 = "0.10"
|
||||
|
||||
# Android has no system OpenSSL to link against, and `native-tls` resolves to
|
||||
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
|
||||
# Without this the build dies at `openssl-sys`: "Could not find directory of
|
||||
# OpenSSL installation".
|
||||
#
|
||||
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
|
||||
# was rustls on Android only, which builds faster — but rustls ships its own root
|
||||
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
|
||||
# does. A self-hosted server behind a private or enterprise CA would then work on
|
||||
# one surface and fail on another, and "the surfaces behave the same" is worth more
|
||||
# than build minutes.
|
||||
#
|
||||
# Declared as a direct dependency purely to turn the feature on: cargo's feature
|
||||
# unification applies it to the copy `native-tls` pulls in transitively.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
openssl-sys = { version = "0.9", features = ["vendored"] }
|
||||
@@ -0,0 +1,14 @@
|
||||
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
|
||||
//!
|
||||
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
||||
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
||||
//! either must not require touching this crate — that separation is the whole point
|
||||
//! (see Scribe note 2730). It was already true before the split: every file here
|
||||
//! carried zero Tauri references, which is what made the extraction a move rather
|
||||
//! than a rewrite.
|
||||
//!
|
||||
//! - `local` — the source of truth. Works with no server and no account.
|
||||
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
|
||||
|
||||
pub mod local;
|
||||
pub mod sync;
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
|
||||
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
|
||||
//! frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//!
|
||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||
//! On save these become labels attached with `via_tag = true`.
|
||||
//!
|
||||
//! Dedupes case-insensitively, preserving first-seen order.
|
||||
//!
|
||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||
//! capture-and-recall surface, and a linking system is organization.
|
||||
|
||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = body.chars().collect();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '#' {
|
||||
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
|
||||
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
||||
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && is_tag_char(chars[j]) {
|
||||
j += 1;
|
||||
}
|
||||
let tag: String = chars[i + 1..j].iter().collect();
|
||||
push_unique(&mut out, &tag);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_tag_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '-'
|
||||
}
|
||||
|
||||
fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
|
||||
out.push(candidate.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tags_basic() {
|
||||
assert_eq!(
|
||||
extract_tags("a #todo and #Work-item_2 here"),
|
||||
vec!["todo", "Work-item_2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_require_letter_start_and_boundary() {
|
||||
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
||||
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_dedupe_case_insensitive() {
|
||||
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
assert!(extract_tags("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! The local-first store: on-device SQLite, and the source of truth for every
|
||||
//! client. A client built on this is fully usable with no server and no account.
|
||||
//!
|
||||
//! Framework-free on purpose. The desktop reaches it through Tauri commands and
|
||||
//! Android through uniffi, but neither of those concerns appears in here.
|
||||
|
||||
pub mod derive;
|
||||
pub mod models;
|
||||
pub mod recur;
|
||||
pub mod retention;
|
||||
pub mod schema;
|
||||
pub mod store;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// The shared database handle. rusqlite connections aren't `Sync`, so a `Mutex`
|
||||
/// serializes access — fine, since operations are quick and a client is single-user.
|
||||
/// How it is held is the caller's business: Tauri manages it as state, Android holds
|
||||
/// it in the uniffi object.
|
||||
pub struct Db(pub Mutex<Connection>);
|
||||
|
||||
impl Db {
|
||||
/// Lock the store, reporting a poisoned lock as a message rather than a panic.
|
||||
///
|
||||
/// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each
|
||||
/// call site. Beyond the repetition, that spelling forces the caller to NAME
|
||||
/// `rusqlite::Connection` in any helper that returns the guard — which would make
|
||||
/// rusqlite a dependency of a layer whose whole point is not to know what the
|
||||
/// store is made of. Returning it from here means callers can bind the guard by
|
||||
/// inference and never name the type.
|
||||
///
|
||||
/// A poisoned lock means some earlier call panicked while holding it. The store
|
||||
/// is not necessarily corrupt, but this connection can't be trusted blind, so it
|
||||
/// surfaces as an error the UI can show instead of a second panic.
|
||||
pub fn conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
|
||||
self.0
|
||||
.lock()
|
||||
.map_err(|_| "the local store lock was poisoned by an earlier panic".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
||||
let conn = Connection::open(path)?;
|
||||
schema::migrate(&conn)?;
|
||||
Ok(Db(Mutex::new(conn)))
|
||||
}
|
||||
|
||||
/// Open a migrated, in-memory store.
|
||||
///
|
||||
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
|
||||
/// dependency of its own just to build a `Db` — which is exactly what the desktop
|
||||
/// crate was doing before the core was extracted. The Android bindings will want the
|
||||
/// same thing.
|
||||
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
schema::migrate(&conn)?;
|
||||
Ok(Db(Mutex::new(conn)))
|
||||
}
|
||||
|
||||
/// A one-line count summary of the store, for the startup log.
|
||||
pub fn summary(db: &Db) -> String {
|
||||
let conn = match db.0.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return "counts unavailable (lock poisoned)".to_string(),
|
||||
};
|
||||
let count = |sql: &str| {
|
||||
conn.query_row(sql, [], |r| r.get::<_, i64>(0))
|
||||
.unwrap_or(-1)
|
||||
};
|
||||
format!(
|
||||
"{} notes, {} labels",
|
||||
count("SELECT COUNT(*) FROM notes"),
|
||||
count("SELECT COUNT(*) FROM labels"),
|
||||
)
|
||||
}
|
||||
@@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Serialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// title if set, else the note's first body line — always present, so body-only
|
||||
/// notes are still nameable and `[[link]]`-able. Derived, never stored.
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
/// Always present, so every note has something to be called. Derived at read time,
|
||||
/// never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub kind: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
@@ -72,7 +71,6 @@ pub struct LinkPreview {
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteRevision {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub body: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
@@ -94,12 +92,6 @@ pub struct TitleEntry {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Backlink {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SavedFilter {
|
||||
pub id: String,
|
||||
@@ -135,15 +127,11 @@ fn default_color() -> String {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NoteCreateInput {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -168,8 +156,6 @@ pub struct Facets {
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub has_reminder: Option<bool>,
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Recurring-reminder math: where a reminder goes when it is marked done.
|
||||
//!
|
||||
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept
|
||||
//! behaviourally identical rather than merely similar. The same note can be
|
||||
//! completed from the web (server code) or from the desktop and Android (this
|
||||
//! code), and the two must land on the same instant — otherwise completing a
|
||||
//! reminder on a phone and then syncing would silently move it relative to
|
||||
//! completing it in a browser, and neither surface would look wrong on its own.
|
||||
//!
|
||||
//! Advancement is measured from the reminder's OWN time, never from now. That is
|
||||
//! what keeps a 09:00 daily reminder at 09:00 after being dealt with at 09:47,
|
||||
//! and a monthly one on the same day of the month.
|
||||
//!
|
||||
//! Known limitation, shared with the server: the arithmetic is in UTC, and a note
|
||||
//! carries no timezone. So a daily reminder crossing a DST boundary keeps its UTC
|
||||
//! time and shifts by an hour locally. Fixing that means storing a zone per note
|
||||
//! and is a change to the wire format, not to this file.
|
||||
|
||||
use chrono::{DateTime, Duration, Months, Utc};
|
||||
|
||||
/// The four intervals every surface offers. Anything else is not a recurrence.
|
||||
pub const RECURRENCES: [&str; 4] = ["daily", "weekly", "monthly", "yearly"];
|
||||
|
||||
/// A recurrence we recognise, or nothing.
|
||||
///
|
||||
/// Values reach the store from three clients and a sync payload, so "not a rule
|
||||
/// we know" is an ordinary case rather than a corruption to shout about.
|
||||
pub fn normalize(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|v| RECURRENCES.contains(v))
|
||||
}
|
||||
|
||||
/// One step forward. `None` for an unrecognised rule.
|
||||
///
|
||||
/// Months and years clamp the day to the target month's length — 31 January plus
|
||||
/// a month is 28 February, and the following step is 28 March rather than back to
|
||||
/// the 31st. `chrono`'s `checked_add_months` does that clamping, matching
|
||||
/// `_add_months` in the Python to the day.
|
||||
fn advance_once(at: DateTime<Utc>, recurrence: &str) -> Option<DateTime<Utc>> {
|
||||
match recurrence {
|
||||
"daily" => at.checked_add_signed(Duration::days(1)),
|
||||
"weekly" => at.checked_add_signed(Duration::weeks(1)),
|
||||
"monthly" => at.checked_add_months(Months::new(1)),
|
||||
"yearly" => at.checked_add_months(Months::new(12)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first fire time strictly after `after`, rolling past anything missed.
|
||||
///
|
||||
/// A phone left in a drawer for a fortnight should not come back to fourteen
|
||||
/// pending occurrences of the same daily reminder — it should come back to
|
||||
/// tomorrow's. `None` when the rule is not one we know, which the caller reads as
|
||||
/// "this reminder is finished".
|
||||
pub fn next_occurrence(
|
||||
remind_at: DateTime<Utc>,
|
||||
recurrence: &str,
|
||||
after: DateTime<Utc>,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let mut next = advance_once(remind_at, recurrence)?;
|
||||
while next <= after {
|
||||
match advance_once(next, recurrence) {
|
||||
// The equality check is a guard against a step that does not move,
|
||||
// which would spin here forever. It cannot happen with the four rules
|
||||
// above; it is cheap insurance against a fifth that does not advance.
|
||||
Some(step) if step != next => next = step,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some(next)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn utc(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Mirrors `test_normalize_recurrence` in `tests/test_notes.py`.
|
||||
#[test]
|
||||
fn only_the_four_known_rules_are_recurrences() {
|
||||
for rule in RECURRENCES {
|
||||
assert_eq!(normalize(Some(rule)), Some(rule));
|
||||
}
|
||||
assert_eq!(normalize(Some("none")), None);
|
||||
assert_eq!(normalize(Some("")), None);
|
||||
assert_eq!(normalize(Some("hourly")), None);
|
||||
assert_eq!(normalize(None), None);
|
||||
}
|
||||
|
||||
/// Mirrors `test_next_occurrence_daily_weekly`.
|
||||
#[test]
|
||||
fn daily_and_weekly_keep_the_time_of_day() {
|
||||
let base = utc(2026, 7, 1, 9, 0);
|
||||
let after = utc(2026, 7, 1, 12, 0);
|
||||
assert_eq!(
|
||||
next_occurrence(base, "daily", after),
|
||||
Some(utc(2026, 7, 2, 9, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
next_occurrence(base, "weekly", after),
|
||||
Some(utc(2026, 7, 8, 9, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors `test_next_occurrence_skips_missed`.
|
||||
#[test]
|
||||
fn missed_occurrences_are_rolled_past_not_queued() {
|
||||
let base = utc(2026, 7, 1, 9, 0);
|
||||
let after = utc(2026, 7, 10, 12, 0);
|
||||
assert_eq!(
|
||||
next_occurrence(base, "daily", after),
|
||||
Some(utc(2026, 7, 11, 9, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors `test_next_occurrence_monthly_clamps_month_end`.
|
||||
#[test]
|
||||
fn monthly_clamps_to_a_shorter_month() {
|
||||
let base = utc(2026, 1, 31, 8, 0);
|
||||
let after = utc(2026, 2, 1, 0, 0);
|
||||
assert_eq!(
|
||||
next_occurrence(base, "monthly", after),
|
||||
Some(utc(2026, 2, 28, 8, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors `test_next_occurrence_yearly_and_none`.
|
||||
#[test]
|
||||
fn yearly_advances_a_year_and_an_unknown_rule_advances_nothing() {
|
||||
let base = utc(2026, 3, 15, 7, 0);
|
||||
let after = utc(2026, 3, 16, 0, 0);
|
||||
assert_eq!(
|
||||
next_occurrence(base, "yearly", after),
|
||||
Some(utc(2027, 3, 15, 7, 0))
|
||||
);
|
||||
assert_eq!(next_occurrence(base, "none", after), None);
|
||||
}
|
||||
|
||||
/// Not in the Python suite, and the one that would bite hardest in practice:
|
||||
/// a monthly reminder set on the 31st must not walk itself back to the 28th
|
||||
/// permanently. Each step is taken from the ORIGINAL date, so February's clamp
|
||||
/// does not become March's date.
|
||||
#[test]
|
||||
fn a_clamped_month_does_not_drag_later_months_back() {
|
||||
let base = utc(2026, 1, 31, 8, 0);
|
||||
// Far enough ahead that the loop takes several steps.
|
||||
let after = utc(2026, 4, 15, 0, 0);
|
||||
// Jan 31 -> Feb 28 -> Mar 28 -> Apr 28. The clamp is sticky once applied,
|
||||
// which matches the server exactly — asserted so a future "fix" to either
|
||||
// side has to change both.
|
||||
assert_eq!(
|
||||
next_occurrence(base, "monthly", after),
|
||||
Some(utc(2026, 4, 28, 8, 0))
|
||||
);
|
||||
}
|
||||
|
||||
/// A reminder completed before it was ever due still moves forward one step,
|
||||
/// rather than staying put and firing again immediately.
|
||||
#[test]
|
||||
fn completing_early_still_advances() {
|
||||
let base = utc(2026, 7, 10, 9, 0);
|
||||
let after = utc(2026, 7, 1, 12, 0);
|
||||
assert_eq!(
|
||||
next_occurrence(base, "daily", after),
|
||||
Some(utc(2026, 7, 11, 9, 0))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,8 @@ mod tests {
|
||||
let when = Utc::now() - age;
|
||||
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES (?1, 'B', ?2, ?2, 1, ?2)",
|
||||
rusqlite::params![id, stamped],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -149,8 +149,8 @@ mod tests {
|
||||
fn an_untrashed_note_is_never_swept() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed)
|
||||
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed)
|
||||
VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -163,8 +163,8 @@ mod tests {
|
||||
// "Age unknown" must never resolve to "delete it".
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -179,8 +179,8 @@ mod tests {
|
||||
let conn = db();
|
||||
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('server', 'B', ?1, ?1, 1, ?1)",
|
||||
rusqlite::params![stamped],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
|
||||
//! offline note can later sync 1:1 with the server. Each syncable row carries local
|
||||
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
|
||||
//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md.
|
||||
//! `#tags` are NOT stored as such (derived at query time into labels), matching
|
||||
//! docs/sync.md.
|
||||
//!
|
||||
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
|
||||
|
||||
@@ -13,7 +14,7 @@ CREATE TABLE notes (
|
||||
title TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list'
|
||||
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -159,6 +160,26 @@ CREATE TABLE prefs (
|
||||
);
|
||||
"#;
|
||||
|
||||
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
|
||||
// a note IS — the column was a mode flag with no enum and no constraint behind it,
|
||||
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
|
||||
// 'list' keeps every one of its items.
|
||||
//
|
||||
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
||||
const SCHEMA_V6: &str = r#"
|
||||
ALTER TABLE notes DROP COLUMN kind;
|
||||
"#;
|
||||
|
||||
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
|
||||
// its NAME is the first non-empty line of that body, falling back to its first item —
|
||||
// derived at read time, never stored (see store::display_title).
|
||||
//
|
||||
// note_revisions loses its copy for the same reason: a revision snapshots a body.
|
||||
const SCHEMA_V7: &str = r#"
|
||||
ALTER TABLE notes DROP COLUMN title;
|
||||
ALTER TABLE note_revisions DROP COLUMN title;
|
||||
"#;
|
||||
|
||||
/// Bring the database up to the latest schema. Idempotent.
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
@@ -183,5 +204,13 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(SCHEMA_V5)?;
|
||||
conn.execute_batch("PRAGMA user_version = 5;")?;
|
||||
}
|
||||
if version < 6 {
|
||||
conn.execute_batch(SCHEMA_V6)?;
|
||||
conn.execute_batch("PRAGMA user_version = 6;")?;
|
||||
}
|
||||
if version < 7 {
|
||||
conn.execute_batch(SCHEMA_V7)?;
|
||||
conn.execute_batch("PRAGMA user_version = 7;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,13 +7,14 @@
|
||||
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
|
||||
//! up with the values the frontend sends.
|
||||
|
||||
use chrono::{Duration, SecondsFormat, Utc};
|
||||
use chrono::{DateTime, Duration, SecondsFormat, Utc};
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::local::derive;
|
||||
use crate::local::models::*;
|
||||
use crate::local::recur;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
@@ -23,30 +24,26 @@ fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// title if non-empty, else the first non-blank body line — always a string.
|
||||
fn display_title(title: Option<&str>, body: &str) -> String {
|
||||
if let Some(t) = title {
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
return t.to_string();
|
||||
}
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
///
|
||||
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
|
||||
/// twice, and they have to agree or a synced note is called different things on either
|
||||
/// side of the wire.
|
||||
///
|
||||
/// Pure, and given the items rather than fetching them: every caller has already
|
||||
/// loaded them, so a query here would be a second trip for something already in hand.
|
||||
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
|
||||
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
|
||||
return line.to_string();
|
||||
}
|
||||
body.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
items
|
||||
.iter()
|
||||
.map(|i| i.text.trim())
|
||||
.find(|t| !t.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_title(raw: &str) -> Option<String> {
|
||||
let t = raw.trim();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_like(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
@@ -138,33 +135,29 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
|
||||
|
||||
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
let mut note = conn.query_row(
|
||||
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||||
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||||
FROM notes WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
let dt = display_title(title.as_deref(), &body);
|
||||
let body: String = r.get(1)?;
|
||||
Ok(Note {
|
||||
id: r.get(0)?,
|
||||
title,
|
||||
display_title: dt,
|
||||
display_title: String::new(), // filled below — it may need a query
|
||||
body,
|
||||
color: r.get(3)?,
|
||||
kind: r.get(4)?,
|
||||
position: r.get(5)?,
|
||||
pinned: r.get(6)?,
|
||||
archived: r.get(7)?,
|
||||
trashed: r.get(8)?,
|
||||
deleted_at: r.get(13)?,
|
||||
remind_at: r.get(9)?,
|
||||
recurrence: r.get(10)?,
|
||||
color: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
pinned: r.get(4)?,
|
||||
archived: r.get(5)?,
|
||||
trashed: r.get(6)?,
|
||||
deleted_at: r.get(11)?,
|
||||
remind_at: r.get(7)?,
|
||||
recurrence: r.get(8)?,
|
||||
labels: Vec::new(),
|
||||
items: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
previews: Vec::new(),
|
||||
created_at: r.get(11)?,
|
||||
updated_at: r.get(12)?,
|
||||
created_at: r.get(9)?,
|
||||
updated_at: r.get(10)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
@@ -172,6 +165,8 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
note.items = load_items(conn, id)?;
|
||||
note.attachments = load_attachments(conn, id)?;
|
||||
note.previews = load_previews(conn, id)?;
|
||||
// After the items, because a body-only-empty note is named by its first one.
|
||||
note.display_title = display_title(¬e.body, ¬e.items);
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
@@ -267,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
|
||||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||
let pat = format!("%{}%", escape_like(text));
|
||||
binds.push(pat.clone());
|
||||
binds.push(pat);
|
||||
@@ -276,10 +271,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
sql.push_str(" AND color = ?");
|
||||
binds.push(c.to_string());
|
||||
}
|
||||
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND kind = ?");
|
||||
binds.push(k.to_string());
|
||||
}
|
||||
if f.has_reminder == Some(true) {
|
||||
sql.push_str(" AND remind_at IS NOT NULL");
|
||||
}
|
||||
@@ -325,23 +316,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||||
}
|
||||
|
||||
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
Ok(TitleEntry {
|
||||
id: r.get(0)?,
|
||||
title: display_title(title.as_deref(), &body),
|
||||
// Names come from `load_note` rather than from a bare row, because a note whose
|
||||
// body is empty is named by its first checklist item — which a row here doesn't
|
||||
// have. The command palette reads this; correctness beats one query per note at
|
||||
// personal scale.
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let note = load_note(conn, id)?;
|
||||
Ok(TitleEntry {
|
||||
id: note.id,
|
||||
title: note.display_title,
|
||||
})
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||
let pat = format!("%{}%", escape_like(q));
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
|
||||
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
@@ -349,77 +348,20 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||
}
|
||||
|
||||
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
|
||||
let target: String = {
|
||||
let (t, b): (Option<String>, String) =
|
||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
display_title(t.as_deref(), &b)
|
||||
};
|
||||
if target.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
|
||||
let rows = stmt.query_map([id], |r| {
|
||||
let nid: String = r.get(0)?;
|
||||
let t: Option<String> = r.get(1)?;
|
||||
let b: String = r.get(2)?;
|
||||
Ok((nid, t, b))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (nid, t, b) = row?;
|
||||
if derive::extract_links(&b)
|
||||
.iter()
|
||||
.any(|l| l.eq_ignore_ascii_case(&target))
|
||||
{
|
||||
out.push(Backlink {
|
||||
id: nid,
|
||||
title: display_title(t.as_deref(), &b),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let ql = q.trim().to_lowercase();
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let id: String = r.get(0)?;
|
||||
let t: Option<String> = r.get(1)?;
|
||||
let b: String = r.get(2)?;
|
||||
Ok((id, t, b))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, t, b) = row?;
|
||||
let dt = display_title(t.as_deref(), &b);
|
||||
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
|
||||
out.push(TitleEntry { id, title: dt });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- notes: write -----------------------------------------------------------
|
||||
|
||||
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
||||
let id = new_id();
|
||||
let ts = now();
|
||||
let title = normalize_title(&input.title);
|
||||
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
|
||||
let position: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
|
||||
params![id, title, input.body, input.color, kind, position, ts],
|
||||
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
|
||||
params![id, input.body, input.color, position, ts],
|
||||
)?;
|
||||
if let Some(items) = &input.items {
|
||||
for (i, text) in items.iter().enumerate() {
|
||||
@@ -433,25 +375,12 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
||||
load_note(conn, &id)
|
||||
}
|
||||
|
||||
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
|
||||
let input = NoteCreateInput {
|
||||
title: title.to_string(),
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
kind: None,
|
||||
items: None,
|
||||
};
|
||||
create_note(conn, &input)
|
||||
}
|
||||
|
||||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let (title, body): (Option<String>, String) =
|
||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
let body: String =
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||||
conn.execute(
|
||||
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![new_id(), id, title, body, now()],
|
||||
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, body, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -462,20 +391,13 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
.as_object()
|
||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||
|
||||
// Snapshot the pre-edit title/body once if either is being changed (version history).
|
||||
if obj.contains_key("title") || obj.contains_key("body") {
|
||||
// Snapshot the pre-edit body before changing it (version history).
|
||||
if obj.contains_key("body") {
|
||||
snapshot_revision(conn, id)?;
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"title" => {
|
||||
let norm = v.as_str().and_then(normalize_title);
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1 WHERE id = ?2",
|
||||
params![norm, id],
|
||||
)?;
|
||||
}
|
||||
"body" => {
|
||||
let body = v.as_str().unwrap_or("");
|
||||
conn.execute(
|
||||
@@ -489,11 +411,6 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"kind" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"pinned" => {
|
||||
if let Some(b) = v.as_bool() {
|
||||
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
||||
@@ -529,9 +446,38 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
/// Mark a reminder handled.
|
||||
///
|
||||
/// A recurring reminder advances to its next occurrence; a one-off clears both
|
||||
/// `remind_at` AND `recurrence`. Clearing the rule as well matters: without it a
|
||||
/// note whose recurrence is a value we do not recognise would keep that value
|
||||
/// forever, invisible in every UI (they only render known rules) and waiting to
|
||||
/// mean something the day the vocabulary grows.
|
||||
///
|
||||
/// Same behaviour as the server's `POST /<id>/reminder/complete`, deliberately —
|
||||
/// the same note can be completed from a browser or from a client, and a
|
||||
/// disagreement here would move a reminder depending on which one you used.
|
||||
pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
// Clear the reminder. (Recurrence advancement is a later refinement.)
|
||||
conn.execute("UPDATE notes SET remind_at = NULL WHERE id = ?1", [id])?;
|
||||
let note = load_note(conn, id)?;
|
||||
let next = note
|
||||
.remind_at
|
||||
.as_deref()
|
||||
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
|
||||
.and_then(|at| {
|
||||
let rule = recur::normalize(note.recurrence.as_deref())?;
|
||||
recur::next_occurrence(at.with_timezone(&Utc), rule, Utc::now())
|
||||
});
|
||||
|
||||
match next {
|
||||
Some(at) => conn.execute(
|
||||
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
|
||||
params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id],
|
||||
)?,
|
||||
None => conn.execute(
|
||||
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
|
||||
[id],
|
||||
)?,
|
||||
};
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
@@ -700,28 +646,27 @@ pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<(
|
||||
|
||||
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
||||
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
||||
let rows = stmt.query_map([id], |r| {
|
||||
Ok(NoteRevision {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
body: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
body: r.get(1)?,
|
||||
created_at: r.get(2)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
||||
let (title, body): (Option<String>, String) = conn.query_row(
|
||||
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||
let body: String = conn.query_row(
|
||||
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||
params![rev_id, id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
snapshot_revision(conn, id)?;
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
|
||||
params![title, body, id],
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, &body)?;
|
||||
touch(conn, id)?;
|
||||
@@ -8,6 +8,7 @@
|
||||
//! Nothing here runs unless the user has linked a server; the app is local-first and
|
||||
//! fully usable with no network at all.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{RequestBuilder, StatusCode};
|
||||
@@ -58,6 +59,64 @@ struct DeviceLoginResponse {
|
||||
user: Identity,
|
||||
}
|
||||
|
||||
/// What became of this device's token on the SERVER when unlinking.
|
||||
///
|
||||
/// Not a bool, and not an error: unlinking must never be blocked by the network —
|
||||
/// wanting to stop syncing is a local decision — so the remote half reports back
|
||||
/// instead of failing the call, and each outcome needs different advice.
|
||||
///
|
||||
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum RevokeOutcome {
|
||||
/// The server confirmed it: this token authenticates nothing now.
|
||||
Revoked,
|
||||
/// This server has no self-revoke route — it predates one. The token is still
|
||||
/// live, and only the web app can retire it.
|
||||
Unsupported,
|
||||
/// We couldn't reach the server, or it refused. The token is still live.
|
||||
Failed { reason: String },
|
||||
/// Nothing to revoke; the app wasn't linked.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Retire the device token we authenticate with, server-side.
|
||||
///
|
||||
/// Identified by the token itself rather than a device id, because a token pasted
|
||||
/// from the web app never carried one — a route keyed on the id would work for
|
||||
/// exactly one of the two ways this app can be linked.
|
||||
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
|
||||
let client = match http() {
|
||||
Ok(client) => client,
|
||||
Err(reason) => return RevokeOutcome::Failed { reason },
|
||||
};
|
||||
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
return RevokeOutcome::Failed {
|
||||
reason: describe_transport_error(base_url, &e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
// 401 counts as revoked: the token already authenticates nothing — retired by
|
||||
// another device, or purged server-side — which is the state we were asking for.
|
||||
if status.is_success() || status == StatusCode::UNAUTHORIZED {
|
||||
return RevokeOutcome::Revoked;
|
||||
}
|
||||
match status {
|
||||
// No such route: a server older than self-revoke. Any other shape of 404
|
||||
// (a proxy, a stale base URL) leaves the token live too, so the advice the
|
||||
// user needs is the same either way.
|
||||
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
|
||||
other => RevokeOutcome::Failed {
|
||||
reason: unexpected_status(base_url, other),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
@@ -300,6 +359,11 @@ fn me_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/me")
|
||||
}
|
||||
|
||||
/// `self` rather than a device id: see `revoke_self`.
|
||||
fn revoke_self_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/devices/self")
|
||||
}
|
||||
|
||||
/// Turn a transport failure into something a person can act on. reqwest's own
|
||||
/// Display is accurate but reads like a stack trace.
|
||||
fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
|
||||
@@ -341,6 +405,23 @@ mod tests {
|
||||
me_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/me"
|
||||
);
|
||||
assert_eq!(
|
||||
revoke_self_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/devices/self"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_outcome_serializes_tagged_for_the_frontend() {
|
||||
// The UI decides between "signed out on the server" and "still valid, go
|
||||
// revoke it" by reading this tag, so its shape is part of the contract.
|
||||
let json = serde_json::to_string(&RevokeOutcome::Failed {
|
||||
reason: "offline".into(),
|
||||
})
|
||||
.expect("outcome serializes");
|
||||
assert!(json.contains("\"status\":\"failed\""), "got {json}");
|
||||
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
|
||||
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -351,3 +432,138 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Android client a linked server can hand out.
|
||||
///
|
||||
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
|
||||
/// means the server has no client to offer, which is an ordinary state and not an
|
||||
/// error — a self-hoster who never touches Android has one.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ClientRelease {
|
||||
pub version: String,
|
||||
/// What decides "is this newer". The name is for people and sorts like a string.
|
||||
pub version_code: i64,
|
||||
pub size: i64,
|
||||
pub sha256: String,
|
||||
/// Path on the same server, not an absolute URL — the client joins it to the
|
||||
/// base it is already linked to, so a compromised or misconfigured server
|
||||
/// cannot redirect the download somewhere else.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// What Android client the linked server has, if any.
|
||||
///
|
||||
/// `Ok(None)` for a server that simply has none — that is the answer to the
|
||||
/// question, not a failure to answer it.
|
||||
pub async fn fetch_client_release(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<Option<ClientRelease>, String> {
|
||||
let url = format!("{base_url}/api/client/android");
|
||||
let response = prepare(http()?.get(url), Some(token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<ClientRelease>()
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|e| {
|
||||
format!("{base_url} described its Android client in a way this app could not read: {e}")
|
||||
})
|
||||
}
|
||||
|
||||
/// Download the client to `dest`, verifying it on the way in.
|
||||
///
|
||||
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
|
||||
/// a phone, on top of whatever the app is already using, is how an update gets
|
||||
/// killed by the low-memory killer half way through.
|
||||
///
|
||||
/// Written to `dest.part` and renamed only once the digest matches, so an
|
||||
/// interrupted download can never be mistaken for a finished one. The digest is
|
||||
/// not a trust anchor — the APK signature is, and Android checks that at install —
|
||||
/// but it catches a truncated or corrupted transfer before the installer is
|
||||
/// bothered with it.
|
||||
pub async fn download_client(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
release: &ClientRelease,
|
||||
dest: &Path,
|
||||
) -> Result<(), String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Write;
|
||||
|
||||
// The advertised path is joined to the base we are LINKED to. Taking an
|
||||
// absolute URL from the response would let a server point the download at a
|
||||
// host the user never agreed to.
|
||||
let path = release.url.trim_start_matches('/');
|
||||
let url = format!("{base_url}/{path}");
|
||||
|
||||
let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
let partial = dest.with_extension("part");
|
||||
if let Some(parent) = partial.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
|
||||
}
|
||||
let mut file = std::fs::File::create(&partial)
|
||||
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut written: i64 = 0;
|
||||
loop {
|
||||
let chunk = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
let Some(chunk) = chunk else { break };
|
||||
hasher.update(&chunk);
|
||||
written += chunk.len() as i64;
|
||||
file.write_all(&chunk)
|
||||
.map_err(|e| format!("Couldn't write the download: {e}"))?;
|
||||
}
|
||||
file.flush()
|
||||
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
|
||||
drop(file);
|
||||
|
||||
let digest = format!("{:x}", hasher.finalize());
|
||||
let mismatch = if written != release.size {
|
||||
Some(format!("expected {} bytes, got {written}", release.size))
|
||||
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
|
||||
Some("the contents did not match the checksum the server published".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(why) = mismatch {
|
||||
// The half-file is removed rather than left: a later run finding it would
|
||||
// have no way to tell it from a good one.
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
return Err(format!("The download from {base_url} was damaged — {why}."));
|
||||
}
|
||||
|
||||
std::fs::rename(&partial, dest)
|
||||
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
|
||||
}
|
||||
@@ -19,11 +19,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 1;
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||
/// server's `min_client_protocol_version`.
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1;
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
||||
/// link rather than degrading it.
|
||||
@@ -344,13 +344,17 @@ mod tests {
|
||||
fn server_info_tolerates_unknown_and_absent_fields() {
|
||||
// Forward compatibility: a NEWER server sending fields we've never heard of
|
||||
// must not break the handshake.
|
||||
let info: ServerInfo = serde_json::from_str(
|
||||
r#"{"site_name":"S","sync_protocol_version":1,
|
||||
"min_client_protocol_version":1,
|
||||
// Versions come from the constants, not literals: this test is about unknown
|
||||
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
|
||||
// to v2 — for a reason that has nothing to do with what it checks.
|
||||
let body = format!(
|
||||
r#"{{"site_name":"S","sync_protocol_version":{v},
|
||||
"min_client_protocol_version":{v},
|
||||
"sync_features":["notes","labels","attachments","tombstones","revisions"],
|
||||
"some_future_field":{"nested":true}}"#,
|
||||
)
|
||||
.expect("unknown fields are ignored");
|
||||
"some_future_field":{{"nested":true}}}}"#,
|
||||
v = CLIENT_PROTOCOL_VERSION,
|
||||
);
|
||||
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
|
||||
assert_eq!(evaluate(&info), Compatibility::Ok);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
//! be talked to at all. Pure decision logic, no I/O.
|
||||
//! - `client` — HTTP transport: the handshake call and device-token auth.
|
||||
//! - `state` — the persisted link record (server, token, change-feed cursor).
|
||||
//! - `commands` — the Tauri surface the Settings UI drives.
|
||||
//! - `engine` — one full cycle: push local changes, then pull the server's.
|
||||
//!
|
||||
//! The engine that moves notes — push, pull, last-write-wins — lands in M10.7b/c and
|
||||
//! consults `compat` before it does anything.
|
||||
//! The UI surface that drives this lives in whichever client is wrapping the crate,
|
||||
//! not here.
|
||||
|
||||
pub mod blobs;
|
||||
pub mod client;
|
||||
pub mod commands;
|
||||
pub mod compat;
|
||||
pub mod engine;
|
||||
pub mod pull;
|
||||
@@ -240,15 +240,13 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
|
||||
// never changes, and the server's copy is the same value anyway.
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||
"INSERT INTO notes (id, body, color, position, pinned, archived,
|
||||
trashed, remind_at, recurrence, created_at, updated_at,
|
||||
sync_revision, trashed_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
body = excluded.body,
|
||||
color = excluded.color,
|
||||
kind = excluded.kind,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
@@ -261,10 +259,8 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
dirty = 0",
|
||||
params![
|
||||
note.id,
|
||||
note.title,
|
||||
note.body,
|
||||
note.color,
|
||||
note.kind,
|
||||
note.position,
|
||||
note.pinned,
|
||||
note.archived,
|
||||
@@ -498,10 +494,8 @@ mod tests {
|
||||
fn note(id: &str, revision: i64) -> wire::Note {
|
||||
wire::Note {
|
||||
id: id.to_string(),
|
||||
title: Some("Title".into()),
|
||||
body: "Body".into(),
|
||||
color: "default".into(),
|
||||
kind: "text".into(),
|
||||
position: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
@@ -62,15 +62,11 @@ pub struct Change {
|
||||
pub op: &'static str,
|
||||
pub edited_at: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pinned: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archived: Option<bool>,
|
||||
@@ -99,10 +95,8 @@ impl Change {
|
||||
id,
|
||||
op: "delete",
|
||||
edited_at,
|
||||
title: None,
|
||||
body: None,
|
||||
color: None,
|
||||
kind: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
trashed: None,
|
||||
@@ -200,9 +194,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
||||
name: Some(r.get(1)?),
|
||||
color: Some(r.get(2)?),
|
||||
edited_at: r.get(3)?,
|
||||
title: None,
|
||||
body: None,
|
||||
kind: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
trashed: None,
|
||||
@@ -237,10 +229,8 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
|
||||
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
|
||||
/// field-to-column mapping stays readable at the call site.
|
||||
struct NoteRow {
|
||||
title: Option<String>,
|
||||
body: String,
|
||||
color: String,
|
||||
kind: String,
|
||||
position: i64,
|
||||
pinned: bool,
|
||||
archived: bool,
|
||||
@@ -253,24 +243,22 @@ struct NoteRow {
|
||||
|
||||
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
conn.query_row(
|
||||
"SELECT title, body, color, kind, position, pinned, archived, trashed,
|
||||
"SELECT body, color, position, pinned, archived, trashed,
|
||||
remind_at, recurrence, created_at, updated_at
|
||||
FROM notes WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok(NoteRow {
|
||||
title: r.get(0)?,
|
||||
body: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
kind: r.get(3)?,
|
||||
position: r.get(4)?,
|
||||
pinned: r.get::<_, i64>(5)? != 0,
|
||||
archived: r.get::<_, i64>(6)? != 0,
|
||||
trashed: r.get::<_, i64>(7)? != 0,
|
||||
remind_at: r.get(8)?,
|
||||
recurrence: r.get(9)?,
|
||||
created_at: r.get(10)?,
|
||||
updated_at: r.get(11)?,
|
||||
body: r.get(0)?,
|
||||
color: r.get(1)?,
|
||||
position: r.get(2)?,
|
||||
pinned: r.get::<_, i64>(3)? != 0,
|
||||
archived: r.get::<_, i64>(4)? != 0,
|
||||
trashed: r.get::<_, i64>(5)? != 0,
|
||||
remind_at: r.get(6)?,
|
||||
recurrence: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
updated_at: r.get(9)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -309,10 +297,8 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
// The local `updated_at` IS the client's edit time, which is what the
|
||||
// server's last-write-wins comparison runs against.
|
||||
edited_at: row.updated_at,
|
||||
title: row.title,
|
||||
body: Some(row.body),
|
||||
color: Some(row.color),
|
||||
kind: Some(row.kind),
|
||||
pinned: Some(row.pinned),
|
||||
archived: Some(row.archived),
|
||||
trashed: Some(row.trashed),
|
||||
@@ -535,9 +521,9 @@ mod tests {
|
||||
|
||||
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||
"INSERT INTO notes (id, body, color, position, pinned, archived,
|
||||
trashed, created_at, updated_at, sync_revision, dirty)
|
||||
VALUES (?1, 'T', 'B', 'default', 'text', 0, 0, 0, 0,
|
||||
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
|
||||
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
|
||||
params![id, dirty],
|
||||
)
|
||||
@@ -24,13 +24,9 @@ pub struct ChangesPage {
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default = "default_kind")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub position: i64,
|
||||
#[serde(default)]
|
||||
@@ -157,10 +153,6 @@ fn default_color() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_kind() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
|
||||
fn default_mime() -> String {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
@@ -20,7 +20,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
BUNDLE_DIR="$REPO_ROOT/desktop/src-tauri/target/release/bundle/appimage"
|
||||
BUNDLE_DIR="$REPO_ROOT/target/release/bundle/appimage"
|
||||
|
||||
# appimagetool is published only under the rolling "continuous" tag (the project
|
||||
# cuts no semver releases), so this URL is the pinned distribution channel.
|
||||
@@ -90,7 +90,7 @@ fi
|
||||
echo "==> Locating appimagetool"
|
||||
# Prefer the copy Tauri already downloaded during the build (no network, and
|
||||
# version-matched to the toolchain that produced the AppImage).
|
||||
APPIMAGETOOL="$(find "$REPO_ROOT/desktop/src-tauri/target" -name 'appimagetool-*.AppImage' -type f 2>/dev/null | head -n1 || true)"
|
||||
APPIMAGETOOL="$(find "$REPO_ROOT/target" -name 'appimagetool-*.AppImage' -type f 2>/dev/null | head -n1 || true)"
|
||||
if [ -z "${APPIMAGETOOL:-}" ]; then
|
||||
echo " not cached by Tauri; downloading from continuous channel"
|
||||
APPIMAGETOOL="$WORK/appimagetool"
|
||||
|
||||
@@ -15,15 +15,22 @@ Easiest — the one-command installer picks this package automatically on any
|
||||
pacman system:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/main/desktop/packaging/install.sh | sh
|
||||
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
|
||||
```
|
||||
|
||||
That installs the newest tagged release. To follow the rolling development
|
||||
channel instead, pass the flag through the pipe:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh -s -- --channel dev
|
||||
```
|
||||
|
||||
Or grab the `.pkg.tar.*` from the
|
||||
[latest release](https://git.fabledsword.com/bvandeusen/thoughtsync/releases/latest)
|
||||
[releases page](https://git.fabledsword.com/bvandeusen/thoughtsync/releases)
|
||||
and install it directly:
|
||||
|
||||
```sh
|
||||
sudo pacman -U thoughtsync-desktop-*-x86_64.pkg.tar.*
|
||||
sudo pacman -U thoughtsync-*-x86_64.pkg.tar.*
|
||||
```
|
||||
|
||||
The compression suffix depends on what the build image provides — `.zst` when
|
||||
@@ -38,7 +45,11 @@ Either way you get:
|
||||
|
||||
Launch **ThoughtSync** from your app menu, or run `thoughtsync`.
|
||||
|
||||
Uninstall: `sudo pacman -R thoughtsync-desktop`.
|
||||
Uninstall: `sudo pacman -R thoughtsync`.
|
||||
|
||||
The package was called `thoughtsync-desktop` before; it declares `replaces`/
|
||||
`conflicts` on that name, so an upgrade from it is a normal `pacman -U` and
|
||||
leaves nothing behind.
|
||||
|
||||
## How the package is built
|
||||
|
||||
|
||||
@@ -29,10 +29,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
SRC_TAURI="$REPO_ROOT/desktop/src-tauri"
|
||||
BINARY="$SRC_TAURI/target/release/thoughtsync-desktop"
|
||||
OUT_DIR="${1:-$SRC_TAURI/target/release/bundle/arch}"
|
||||
BINARY="$REPO_ROOT/target/release/thoughtsync"
|
||||
OUT_DIR="${1:-$REPO_ROOT/target/release/bundle/arch}"
|
||||
|
||||
PKGNAME="thoughtsync-desktop"
|
||||
PKGNAME="thoughtsync"
|
||||
# The name this package used to ship under. pacman needs both to retire it: without
|
||||
# them a `pacman -U` of the renamed package installs ALONGSIDE the old one, and two
|
||||
# packages both own /usr/bin/thoughtsync (issue 2075).
|
||||
REPLACES=(thoughtsync-desktop)
|
||||
PKGREL=1
|
||||
PKGDESC="ThoughtSync desktop — local-first Keep-style thought capture"
|
||||
URL="https://git.fabledsword.com/bvandeusen/thoughtsync"
|
||||
@@ -51,14 +55,17 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Single source of truth for the version: the same tauri.conf.json value the
|
||||
# .deb and the AppImage are stamped with, so all three artifacts on a release
|
||||
# always agree. Plain grep — jq is not guaranteed in the CI image.
|
||||
# Single source of truth for the version: the SAME helper the bundle build uses.
|
||||
#
|
||||
# It used to read tauri.conf.json directly, which was right until dev builds began
|
||||
# overriding the version on the command line (M10.9) — the file still says 0.1.0, so
|
||||
# the pacman package came out stamped 0.1.0 around a binary reporting 0.1.132. A
|
||||
# package that lies about its version is exactly what makes a later "which build is
|
||||
# this?" question unanswerable.
|
||||
# `|| true` so a miss falls through to the explicit error below rather than
|
||||
# aborting on pipefail with no explanation.
|
||||
PKGVER="$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' "$SRC_TAURI/tauri.conf.json" |
|
||||
head -1 | sed -E 's/.*"([^"]+)"$/\1/' || true)"
|
||||
[ -n "$PKGVER" ] || { echo "ERROR: could not read version from tauri.conf.json" >&2; exit 1; }
|
||||
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)"
|
||||
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
|
||||
|
||||
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
|
||||
# commit produces the same builddate.
|
||||
@@ -70,9 +77,10 @@ STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT INT TERM
|
||||
|
||||
# --- lay out the filesystem tree --------------------------------------------
|
||||
# /usr/bin/thoughtsync (not thoughtsync-desktop): matches the CLI name the
|
||||
# AppImage installer symlinks into ~/.local/bin, so the command is the same
|
||||
# whichever way the app was installed.
|
||||
# /usr/bin/thoughtsync — the same command name the .deb installs and the AppImage
|
||||
# installer symlinks into ~/.local/bin, so it's identical whichever way the app
|
||||
# arrived. The binary already carries this name (Cargo `[[bin]]`), which is also
|
||||
# what the .desktop entry's StartupWMClass has to match.
|
||||
install -Dm755 "$BINARY" "$STAGE/usr/bin/thoughtsync"
|
||||
install -Dm644 "$SCRIPT_DIR/thoughtsync.desktop" \
|
||||
"$STAGE/usr/share/applications/thoughtsync.desktop"
|
||||
@@ -106,6 +114,10 @@ INSTALLED_SIZE="$(du -sb "$STAGE" | cut -f1)"
|
||||
echo "arch = x86_64"
|
||||
echo "license = $LICENSE"
|
||||
for d in "${DEPENDS[@]}"; do echo "depend = $d"; done
|
||||
# conflict + replaces together: `conflict` is what makes pacman remove the old
|
||||
# package rather than refuse the transaction, `replaces` is what makes an upgrade
|
||||
# pick this one up under its new name.
|
||||
for r in "${REPLACES[@]}"; do echo "conflict = $r"; echo "replaces = $r"; done
|
||||
} >"$STAGE/.PKGINFO"
|
||||
|
||||
# --- .MTREE (optional) ------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# StartupWMClass must equal the BINARY name, not the product name: GTK derives
|
||||
# WM_CLASS from the executable, so anything else silently breaks taskbar icon
|
||||
# grouping. Tauri writes the same value into the .deb's generated entry.
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=ThoughtSync
|
||||
@@ -6,4 +9,4 @@ Exec=thoughtsync %U
|
||||
Icon=thoughtsync
|
||||
Terminal=false
|
||||
Categories=Utility;Office;
|
||||
StartupWMClass=ThoughtSync
|
||||
StartupWMClass=thoughtsync
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user