Compare commits
307
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee5e57ba8 | ||
|
|
947edab341 | ||
|
|
96b2da1753 | ||
|
|
0a2ee66f6f | ||
|
|
ad8fd1e24f | ||
|
|
b5655de214 | ||
|
|
071034de65 | ||
|
|
bbc0715666 | ||
|
|
e3d54aadd2 | ||
|
|
6fff3fcf02 | ||
|
|
8e44d1000c | ||
|
|
df5c1aa2f9 | ||
|
|
2df6b0f82a | ||
|
|
79c3f57834 | ||
|
|
5e5116cbca | ||
|
|
d9cfff80ec | ||
|
|
1663a2781b | ||
|
|
9c6a969c17 | ||
|
|
b7c7415fef | ||
|
|
1634c57837 | ||
|
|
ca437671e3 | ||
|
|
bae25b20f3 | ||
|
|
23fa389c0a | ||
|
|
c3cd567da2 | ||
|
|
4872d24981 | ||
|
|
af22467817 | ||
|
|
04b856e10c | ||
|
|
fd80bded67 | ||
|
|
2ce2cb39d2 | ||
|
|
5826317182 | ||
|
|
4f588b8fd1 | ||
|
|
4dd2c20053 | ||
|
|
cf274781bd | ||
|
|
33c826b7ec | ||
|
|
b4a450a8b8 | ||
|
|
ef43dc9a79 | ||
|
|
5120d65793 | ||
|
|
92c97a386b | ||
|
|
62c9279700 | ||
|
|
aafb4f8f6a | ||
|
|
046c21cdaa | ||
|
|
5ee1cbeecf | ||
|
|
ec88548f25 | ||
|
|
1d63d6c7e1 | ||
|
|
ecbc052389 | ||
|
|
455948774d | ||
|
|
0d0bb0d8fe | ||
|
|
421f113349 | ||
|
|
31771e3a4e | ||
|
|
49e22d1873 | ||
|
|
228ea83288 | ||
|
|
0ef26da933 | ||
|
|
d3f0ca46dd | ||
|
|
73f69c5326 | ||
|
|
aff7133bbb | ||
|
|
47b7efade7 | ||
|
|
6a3b3d5a92 | ||
|
|
e22e6c14dd | ||
|
|
bd1f09fc06 | ||
|
|
565a4ebfa8 | ||
|
|
d6100462a7 | ||
|
|
56a6776f67 | ||
|
|
96fb91ce8c | ||
|
|
7b2bef32fc | ||
|
|
7d92aa6feb | ||
|
|
6ff988aaec | ||
|
|
52d99d2bf9 | ||
|
|
41d06c5c77 | ||
|
|
5eebe2e2b1 | ||
|
|
d4412ce5f3 | ||
|
|
e2ab1b8953 | ||
|
|
64918731eb | ||
|
|
66534d6a0e | ||
|
|
60a8e7df1b | ||
|
|
7201ff3eaa | ||
|
|
fce2ae981c | ||
|
|
e475c30f0a | ||
|
|
68e4e2471c | ||
|
|
2b38d06c6d | ||
|
|
036df6b2fe | ||
|
|
9e2b4bf49f | ||
|
|
6cf15c5af5 | ||
|
|
2151bc6d7c | ||
|
|
4b61c5f875 | ||
|
|
3c2bd0b138 | ||
|
|
69be8a5c69 | ||
|
|
6490e5e461 | ||
|
|
5f1e611886 | ||
|
|
72ee1d3774 | ||
|
|
99f6ef19e4 | ||
|
|
f353bdc5ec | ||
|
|
e72a9b15c6 | ||
|
|
30c5251a68 | ||
|
|
97d6f9303b | ||
|
|
788c450cad | ||
|
|
e06054afb6 | ||
|
|
8230eae962 | ||
|
|
a48d68c139 | ||
|
|
01841e9faf | ||
|
|
26d4295c30 | ||
|
|
89c9f688a1 | ||
|
|
31639fdacc | ||
|
|
f535561f64 | ||
|
|
5130597447 | ||
|
|
0a8a463677 | ||
|
|
d0a5ccb10d | ||
|
|
1f65743631 | ||
|
|
d424abc6d3 | ||
|
|
73d6a44a3f | ||
|
|
948f3f1bda | ||
|
|
5777967375 | ||
|
|
6bc5b90315 | ||
|
|
8a8dbcb4d2 | ||
|
|
4a5fd35f2b | ||
|
|
d7594c0b39 | ||
|
|
7a2e3380e9 | ||
|
|
a5c94872e0 | ||
|
|
4287c074d4 | ||
|
|
2848937309 | ||
|
|
3971e89d1e | ||
|
|
24df11a2e4 | ||
|
|
ec418797f9 | ||
|
|
1ae19797d3 | ||
|
|
3b210059db | ||
|
|
579d74e5e0 | ||
|
|
a0e3ffb1f1 | ||
|
|
3ea8621163 | ||
|
|
c84b0605cf | ||
|
|
6758c833b0 | ||
|
|
a6e213490a | ||
|
|
f8ce83d5cb | ||
|
|
9edee519fa | ||
|
|
15c4cd9ae8 | ||
|
|
7e7e92bb02 | ||
|
|
ed9b9e2742 | ||
|
|
ae37823aec | ||
|
|
938554a6eb | ||
|
|
a7a7c323b5 | ||
|
|
275a74d81a | ||
|
|
d877f50fd9 | ||
|
|
5994ee965e | ||
|
|
86e80a41fc | ||
|
|
8bfc31f17c | ||
|
|
f8d03a4343 | ||
|
|
e53a553398 | ||
|
|
37e6115b45 | ||
|
|
01a42a5eb1 | ||
|
|
ec5e3088c0 | ||
|
|
7529a8fa7d | ||
|
|
d9d50f1903 | ||
|
|
9d70bc2932 | ||
|
|
b0bb942a02 | ||
|
|
48333ab01e | ||
|
|
585b674450 | ||
|
|
40a079df5b | ||
|
|
4db91ad893 | ||
|
|
e7bb69d2ac | ||
|
|
e28c52b6bf | ||
|
|
1d6ebe8249 | ||
|
|
9aaf34c95c | ||
|
|
fe4d5c02d1 | ||
|
|
9782a28c57 | ||
|
|
9484c2a25b | ||
|
|
bc1a2b9e11 | ||
|
|
18f52e1cb9 | ||
|
|
611490a9f6 | ||
|
|
9c73654723 | ||
|
|
3154318dc9 | ||
|
|
c39f803c97 | ||
|
|
cb4ce08b4f | ||
|
|
de6fb23989 | ||
|
|
791615da06 | ||
|
|
69e58548ac | ||
|
|
76574b66b5 | ||
|
|
e68edb4ee3 | ||
|
|
10203d8ee9 | ||
|
|
1f57d36292 | ||
|
|
9b970af2d1 | ||
|
|
29e26bc1a6 | ||
|
|
e8c4fc1ea1 | ||
|
|
d5550cc1d9 | ||
|
|
bce82452d0 | ||
|
|
a547c01ff7 | ||
|
|
45edb792a9 | ||
|
|
21a97222d6 | ||
|
|
098d0ccf86 | ||
|
|
57126f2db0 | ||
|
|
d6ffd10396 | ||
|
|
b1d2cff9ec | ||
|
|
7bddc2eb58 | ||
|
|
bb9b7f39c4 | ||
|
|
c9d98b4ca2 | ||
|
|
8d503abd7e | ||
|
|
9ca53de3da | ||
|
|
c18ab42c6c | ||
|
|
5c0e3af38d | ||
|
|
26f9127d84 | ||
|
|
657a3aefea | ||
|
|
89b2aca141 | ||
|
|
c06a387b3c | ||
|
|
63b553ab8e | ||
|
|
2474981c9e | ||
|
|
b698c17cd0 | ||
|
|
2feaa0bb30 | ||
|
|
59e068e326 | ||
|
|
5bc2b4d6b1 | ||
|
|
8cc4ea22f8 | ||
|
|
4e9e21e847 | ||
|
|
295312937d | ||
|
|
2dd47432ea | ||
|
|
85b4597939 | ||
|
|
0350572c9f | ||
|
|
c5c2da8e68 | ||
|
|
4d26a91f59 | ||
|
|
f66ec3d7fb | ||
|
|
195c07aa32 | ||
|
|
67957a3961 | ||
|
|
5283d29d5d | ||
|
|
1d796e3c41 | ||
|
|
bb3dcd8217 | ||
|
|
9f582ecac4 | ||
|
|
32b91a181b | ||
|
|
0b0f6b7018 | ||
|
|
6ea02f588d | ||
|
|
3f7024d05d | ||
|
|
a6436719b1 | ||
|
|
807f2402a5 | ||
|
|
db96134492 | ||
|
|
0880dd7f54 | ||
|
|
d687f11518 | ||
|
|
0dd6933b13 | ||
|
|
eeeb838658 | ||
|
|
1828109894 | ||
|
|
bce597b03c | ||
|
|
4782b24453 | ||
|
|
671fca99a2 | ||
|
|
2cefc4751b | ||
|
|
99ece7b6d7 | ||
|
|
f99c2df85f | ||
|
|
f5c9d4c4d2 | ||
|
|
a6ec00d203 | ||
|
|
f1e9af53f5 | ||
|
|
4a139faf61 | ||
|
|
a5644f4e02 | ||
|
|
77f837e67a | ||
|
|
528cbd94c4 | ||
|
|
9f7540a2f6 | ||
|
|
e20981d083 | ||
|
|
eb0e649c30 | ||
|
|
0f479b2431 | ||
|
|
1d4103747f | ||
|
|
a73b6410a2 | ||
|
|
ec60348c89 | ||
|
|
adcfc7a6dc | ||
|
|
f70bda77d4 | ||
|
|
b3ac6d4353 | ||
|
|
ac7d669133 | ||
|
|
bad597bc42 | ||
|
|
4175b3f440 | ||
|
|
59c9f9d27e | ||
|
|
2ce527ad77 | ||
|
|
c3287b66f4 | ||
|
|
9ab1765116 | ||
|
|
dde081c4a0 | ||
|
|
dbe258126b | ||
|
|
e395e2af1c | ||
|
|
d2b9d990ad | ||
|
|
291c9a2b4d | ||
|
|
f47efadfa6 | ||
|
|
781966e0a0 | ||
|
|
085475bf93 | ||
|
|
df5b99aff9 | ||
|
|
4504d34fc6 | ||
|
|
25e1a8b83c | ||
|
|
876539b514 | ||
|
|
3ea166607c | ||
|
|
b643c3bb61 | ||
|
|
bee5737ec4 | ||
|
|
63b49b10da | ||
|
|
e2e2c36d1d | ||
|
|
51c790d6bf | ||
|
|
d1b0593a8c | ||
|
|
ffbb882227 | ||
|
|
657af48052 | ||
|
|
64fb82ebd9 | ||
|
|
c129701590 | ||
|
|
9ce88a881d | ||
|
|
0754ad4ebf | ||
|
|
921a9a0668 | ||
|
|
c24c53ea01 | ||
|
|
0adf1319f6 | ||
|
|
97950d0b6c | ||
|
|
40290660ae | ||
|
|
3bcbf15e14 | ||
|
|
9083042f2f | ||
|
|
7987333149 | ||
|
|
8e29aebc2e | ||
|
|
b44e0a7887 | ||
|
|
96111b40df | ||
|
|
d090f721e6 | ||
|
|
fe0593dba1 | ||
|
|
b8086f89df | ||
|
|
e175ac13be | ||
|
|
957da0c46f | ||
|
|
13a32df873 | ||
|
|
7e427e1295 | ||
|
|
317ce24054 |
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""PostToolUse (Write|Edit) hook: warn when a Kotlin source file is missing the
|
||||
SPDX license header that every LibreMail source file must carry.
|
||||
|
||||
Non-blocking: prints a JSON warning (systemMessage + additionalContext so Claude
|
||||
adds the header) and always exits 0. Any error is swallowed so the tool flow is
|
||||
never broken by this check.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
REQUIRED = "SPDX-License-Identifier"
|
||||
HEADER_LINE = "// SPDX-License-Identifier: GPL-3.0-or-later"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
path = (data.get("tool_response") or {}).get("filePath") \
|
||||
or (data.get("tool_input") or {}).get("file_path")
|
||||
if not path:
|
||||
return 0
|
||||
|
||||
lower = path.lower()
|
||||
if not (lower.endswith(".kt") or lower.endswith(".kts")):
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
except OSError:
|
||||
# File missing (e.g. a delete) or unreadable — nothing to check.
|
||||
return 0
|
||||
|
||||
if REQUIRED in content:
|
||||
return 0
|
||||
|
||||
msg = f"SPDX header missing in {path}"
|
||||
out = {
|
||||
"systemMessage": msg,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": (
|
||||
f'{path} is missing the license header. Add "{HEADER_LINE}" as the '
|
||||
"first line — every LibreMail source file carries it."
|
||||
),
|
||||
},
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python .claude/hooks/check-spdx.py",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Checking SPDX header"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"skill-creator@claude-plugins-official": true,
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: preflight
|
||||
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt) before pushing or opening a PR. Mirrors the merge gate; does NOT run emulator E2E. Use before treating a change as done.
|
||||
---
|
||||
|
||||
# /preflight
|
||||
|
||||
Run the same fast checks CI enforces on every PR, in order, and report the outcome.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The Gradle daemon must run on **JDK 17–21**. AGP 9.2 fails on JDK 25+. If a build errors
|
||||
with a JDK/AGP version mismatch, check `java -version` / `JAVA_HOME` and point it at a 17–21
|
||||
JDK (e.g. Android Studio's bundled JBR) before retrying.
|
||||
- PowerShell: invoke the wrapper as `.\gradlew`. Git Bash / the Bash tool: `./gradlew`.
|
||||
|
||||
## Steps
|
||||
|
||||
Run these, stopping at the first failure:
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
./gradlew :app:lintDebug
|
||||
./gradlew :app:ktlintCheck :app:detekt
|
||||
```
|
||||
|
||||
`ktlintCheck` + `detekt` are exactly what CI's **Static analysis** job runs — they cover the
|
||||
`test`/`androidTest` source sets that `lintDebug` does not, so a style violation there fails the
|
||||
merge gate even when the build and lint are green. Add `--continue` to any command (e.g.
|
||||
`:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of
|
||||
stopping at the first.
|
||||
|
||||
## Reporting
|
||||
|
||||
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt").
|
||||
- On failure, surface the actual Gradle error and point at the relevant report:
|
||||
- unit tests → `app/build/reports/tests/testDebugUnitTest/`
|
||||
- lint → `app/build/reports/lint-results-debug.html`
|
||||
- ktlint → `app/build/reports/ktlint/` (per source set, e.g. `ktlintTestSourceSetCheck/`)
|
||||
- detekt → `app/build/reports/detekt/`
|
||||
- Do **not** run emulator/E2E (`connectedDebugAndroidTest`) here — that's CI's job unless the
|
||||
user explicitly asks.
|
||||
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_code_style = intellij_idea
|
||||
max_line_length = 120
|
||||
# @Composable functions are PascalCase by convention — don't flag them as bad function names.
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
@@ -9,6 +9,8 @@ gradlew text eol=lf
|
||||
|
||||
# Treat these as binary.
|
||||
*.jar binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Auto-update PR branches
|
||||
|
||||
# When main advances, rebase any auto-merge-armed PR that has fallen behind, so the
|
||||
# "require branches up to date" branch rule doesn't need manual branch updates. Only PRs
|
||||
# with GitHub auto-merge enabled are touched (PR_FILTER: auto_merge) — held/draft PRs are
|
||||
# left alone.
|
||||
#
|
||||
# IMPORTANT: for the branch update to RE-TRIGGER the PR's CI (so it can pass and merge),
|
||||
# this must run with a PAT, not the default GITHUB_TOKEN — pushes made by GITHUB_TOKEN do
|
||||
# not start new workflow runs (GitHub's anti-recursion rule), so the updated PR would sit
|
||||
# with stale checks. Create a fine-grained PAT scoped to this repo with
|
||||
# contents:read/write + pull-requests:read/write and add it as the AUTOUPDATE_TOKEN secret.
|
||||
# Without it this falls back to GITHUB_TOKEN, which updates the branch but will NOT re-run
|
||||
# the PR's checks.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: autoupdate-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
autoupdate:
|
||||
name: Auto-update armed PRs
|
||||
runs-on: ubuntu-latest
|
||||
environment: CI_CD
|
||||
steps:
|
||||
- name: Update behind PRs that have auto-merge enabled
|
||||
uses: chinthakagodawita/autoupdate@0707656cd062a3b0cf8fa9b2cda1d1404d74437e # v1.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOUPDATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PR_FILTER: "auto_merge"
|
||||
MERGE_CONFLICT_ACTION: "ignore"
|
||||
+188
-9
@@ -22,6 +22,10 @@ env:
|
||||
jobs:
|
||||
debug-build:
|
||||
name: Debug build
|
||||
# x86_64: Linux-arm64 runners can't set up this SDK — android-actions/setup-android's sdkmanager
|
||||
# fails (exit 1) on the android-37.0 preview platform, and the emulator package has no arm64-Linux
|
||||
# build. Build/unit-test results are host-arch-independent anyway (R8/AGP/JVM); real arm64
|
||||
# device-ABI coverage would need arm64 emulators, which require macOS hosts.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out source
|
||||
@@ -85,16 +89,58 @@ jobs:
|
||||
path: app/build/reports/tests/testDebugUnitTest/
|
||||
if-no-files-found: warn
|
||||
|
||||
instrumented-tests:
|
||||
name: Instrumented / UI tests (emulator)
|
||||
static-analysis:
|
||||
name: Static analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# AGP configuration needs the SDK even for ktlint/detekt (they run on the :app module).
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Install SDK platform and build-tools
|
||||
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
# --continue so a ktlint failure still lets detekt report (and vice versa).
|
||||
- name: Run ktlint and detekt
|
||||
run: ./gradlew :app:ktlintCheck :app:detekt --continue --stacktrace
|
||||
|
||||
- name: Upload analysis reports
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: static-analysis-reports
|
||||
path: |
|
||||
app/build/reports/ktlint/
|
||||
app/build/reports/detekt/
|
||||
if-no-files-found: warn
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# minSdk is 33; the instrumented suite (SQLCipher, Keystore, Room, Compose UI,
|
||||
# mail providers) needs no API-37-specific behavior, so it runs on a stable,
|
||||
# widely-available emulator image rather than the bleeding-edge android-37.0.
|
||||
api-level: [35]
|
||||
# Every Android API level across the rolling ~7-year support window: minSdk (29 / Android 10,
|
||||
# 2019) through the latest stable. Each level boots its own emulator and runs the full
|
||||
# instrumented + Compose UI (E2E) suite; all of them fan in to the "CI passed" gate. When a
|
||||
# new Android ships, add it and drop the oldest level that has aged out of ~7 years. API 37
|
||||
# (preview) is NOT in this matrix because emulator-runner can't provision its nonstandard
|
||||
# android-37.0 / google_apis_ps16k image (it would wedge the gate) — it's covered separately
|
||||
# by the custom-provisioned `e2e-preview` job below. Keep in sync with
|
||||
# testOptions.managedDevices in app/build.gradle.kts.
|
||||
api-level: [29, 30, 31, 32, 33, 34, 35, 36]
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -144,7 +190,7 @@ jobs:
|
||||
disable-animations: false
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
|
||||
- name: Run instrumented tests
|
||||
- name: Run E2E tests
|
||||
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
@@ -155,10 +201,143 @@ jobs:
|
||||
disable-animations: true
|
||||
script: ./gradlew connectedDebugAndroidTest --stacktrace
|
||||
|
||||
- name: Upload instrumented test report
|
||||
- name: Upload E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: instrumented-test-report-api${{ matrix.api-level }}
|
||||
name: e2e-test-report-api${{ matrix.api-level }}
|
||||
path: app/build/reports/androidTests/connected/
|
||||
if-no-files-found: warn
|
||||
|
||||
# API 37 (Android 17, preview) E2E. Its only system image is the nonstandard
|
||||
# android-37.0 / google_apis_ps16k (16 KB page size), which reactivecircus/android-emulator-runner
|
||||
# can't provision (it builds android-37 / google_apis, neither of which exists), so this job
|
||||
# CUSTOM-PROVISIONS the emulator with sdkmanager/avdmanager/emulator directly. It is REQUIRED:
|
||||
# part of the "CI passed" gate's needs (the preview emulator has proven stable in practice), so a
|
||||
# genuine failure blocks merges. When a stable, emulator-runner-friendly API 37 image ships, fold
|
||||
# 37 into the main `e2e` matrix and delete this job.
|
||||
e2e-preview:
|
||||
name: E2E (API 37 preview)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
API37_IMAGE: "system-images;android-37.0;google_apis_ps16k;x86_64"
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
# The hardware-accelerated emulator needs KVM, which is gated behind a udev rule.
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
# Cache the ~1 GB preview system image so only the first run pays the download.
|
||||
- name: Cache API 37 system image
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
# GitHub-hosted ubuntu runners install the SDK at /usr/local/lib/android/sdk; caching the
|
||||
# image dir (with its package metadata) lets sdkmanager treat it as installed and skip the
|
||||
# re-download on a cache hit.
|
||||
path: /usr/local/lib/android/sdk/system-images/android-37.0
|
||||
key: sysimg-android-37.0-google_apis_ps16k-x86_64
|
||||
|
||||
- name: Install SDK packages + preview system image
|
||||
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" "platform-tools" "emulator" "$API37_IMAGE"
|
||||
|
||||
- name: Create API 37 AVD
|
||||
run: |
|
||||
# avdmanager and the emulator disagree on the default AVD dir when ANDROID_SDK_HOME is set
|
||||
# on the runner (avdmanager writes $ANDROID_SDK_HOME/.android/avd; the emulator looks in
|
||||
# $ANDROID_SDK_HOME/avd), which made the boot step report "Unknown AVD name [api37]". Pin
|
||||
# ANDROID_AVD_HOME so both agree, and carry it to the boot step via $GITHUB_ENV.
|
||||
export ANDROID_AVD_HOME="$HOME/.android/avd"
|
||||
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV"
|
||||
mkdir -p "$ANDROID_AVD_HOME"
|
||||
echo "no" | avdmanager create avd -n api37 -k "$API37_IMAGE" -d pixel_2 --force
|
||||
echo "AVDs visible to the emulator:"; "$ANDROID_SDK_ROOT/emulator/emulator" -list-avds
|
||||
|
||||
- name: Boot emulator and run E2E
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EMU_LOG="${RUNNER_TEMP:-/tmp}/emulator.log"
|
||||
|
||||
boot_emulator() {
|
||||
echo "::group::Start API 37 emulator (attempt $1)"
|
||||
# Capture the emulator's own output — without this a boot failure is invisible.
|
||||
"$ANDROID_SDK_ROOT/emulator/emulator" -avd api37 \
|
||||
-no-window -no-audio -no-boot-anim -no-snapshot -accel on \
|
||||
-gpu swiftshader_indirect -camera-back none -camera-front none > "$EMU_LOG" 2>&1 &
|
||||
# ONE bounded wait covering both device registration and full boot, so a stuck emulator
|
||||
# fails fast instead of hanging the whole job until the 35-min cap (the original bug).
|
||||
if timeout 300 adb wait-for-device shell \
|
||||
'while [ "$(getprop sys.boot_completed | tr -d "\r")" != "1" ]; do sleep 2; done'; then
|
||||
echo "::endgroup::"; return 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::warning::API 37 emulator did not boot within 300s (attempt $1)"
|
||||
adb devices || true
|
||||
echo "--- emulator.log (tail) ---"; tail -120 "$EMU_LOG" || true
|
||||
adb emu kill 2>/dev/null || true
|
||||
sleep 5
|
||||
return 1
|
||||
}
|
||||
|
||||
booted=0
|
||||
for attempt in 1 2; do boot_emulator "$attempt" && { booted=1; break; }; done
|
||||
[ "$booted" = "1" ] || { echo "::error::API 37 preview emulator failed to boot after 2 attempts"; exit 1; }
|
||||
|
||||
adb shell input keyevent 82 || true
|
||||
./gradlew connectedDebugAndroidTest --stacktrace
|
||||
|
||||
- name: Dump emulator log on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "--- emulator.log ---"; tail -200 "${RUNNER_TEMP:-/tmp}/emulator.log" 2>/dev/null || echo "(none)"
|
||||
echo "--- logcat ---"; adb logcat -d 2>/dev/null | tail -120 || echo "(device unavailable)"
|
||||
|
||||
- name: Shut down emulator
|
||||
if: always()
|
||||
run: adb emu kill || true
|
||||
|
||||
- name: Upload E2E (API 37) report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-report-api37-preview
|
||||
path: app/build/reports/androidTests/connected/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Single aggregating gate so branch protection can require ALL CI jobs with one stable status
|
||||
# check. It depends on every job — including each api-level of the E2E matrix — so adding/removing
|
||||
# a matrix level needs no change to branch protection (the per-"(api-level)" check names would
|
||||
# otherwise have to be re-listed each time).
|
||||
ci-passed:
|
||||
name: CI passed
|
||||
if: always()
|
||||
needs: [static-analysis, debug-build, unit-tests, e2e, e2e-preview]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify every required job succeeded
|
||||
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
run: |
|
||||
echo "Required CI jobs did not all succeed:"
|
||||
echo " static-analysis: ${{ needs.static-analysis.result }}"
|
||||
echo " debug-build: ${{ needs.debug-build.result }}"
|
||||
echo " unit-tests: ${{ needs.unit-tests.result }}"
|
||||
echo " e2e: ${{ needs.e2e.result }}"
|
||||
echo " e2e-preview: ${{ needs.e2e-preview.result }}"
|
||||
exit 1
|
||||
|
||||
+492
-29
@@ -1,34 +1,82 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Release pipeline (issue #19): tag → fast CI gate → build + sign → GitHub release,
|
||||
# Google Play, Galaxy Store (stub), S3 archive. Full docs: docs/release.md.
|
||||
#
|
||||
# Every publish/archive stage is gated on its CI secrets and SKIPS with a clear log
|
||||
# message when they are absent, so the workflow runs end-to-end today (before the
|
||||
# store accounts from #16/#17/#18 exist). No secret ever lives in the repo.
|
||||
#
|
||||
# Secrets (all optional; configure in Settings → Secrets and variables → Actions):
|
||||
# Signing RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD,
|
||||
# RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD
|
||||
# → absent: artifacts are built with the debug-key fallback and named
|
||||
# *-unsigned (installable for testing, NOT publishable).
|
||||
# Play PLAY_SERVICE_ACCOUNT_JSON (also requires signing secrets)
|
||||
# Galaxy GALAXY_SERVICE_ACCOUNT_ID, GALAXY_PRIVATE_KEY, GALAXY_CONTENT_ID
|
||||
# (reserved — automated submission is stubbed; see docs/release.md#galaxy-store)
|
||||
# Archive ARCHIVE_S3_BUCKET, ARCHIVE_S3_ACCESS_KEY_ID, ARCHIVE_S3_SECRET_ACCESS_KEY,
|
||||
# ARCHIVE_S3_ENDPOINT (optional, for non-AWS), ARCHIVE_S3_REGION (optional)
|
||||
#
|
||||
# F-Droid needs no job here: it builds signed packages itself from the pushed tag and
|
||||
# the repo's fastlane metadata (issue #18).
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# The normal release path: push an annotated tag like v0.2.0.
|
||||
push:
|
||||
tags: ["v*"]
|
||||
# Manual path: rehearse the pipeline (dry run) or re-run publication for an existing tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to create (e.g. v0.1.0)"
|
||||
required: true
|
||||
description: "Existing tag to (re-)release, e.g. v0.2.0. Leave empty to rehearse against the current branch head (build only)."
|
||||
required: false
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark this GitHub release as a pre-release"
|
||||
dry_run:
|
||||
description: "Dry run: build, sign and checksum only — skip GitHub release, store publication and archiving."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
play_track:
|
||||
description: "Google Play track to publish to."
|
||||
required: false
|
||||
type: choice
|
||||
options: [internal, alpha, beta, production]
|
||||
default: internal
|
||||
play_rollout_fraction:
|
||||
description: "Staged-rollout user fraction for the production track (0 < f < 1, e.g. 0.10), or 1.0 for a full rollout. Ignored on other tracks."
|
||||
required: false
|
||||
type: string
|
||||
default: "0.10"
|
||||
|
||||
# Never cancel a half-finished publication; queue instead.
|
||||
concurrency:
|
||||
group: release-${{ inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Needed to create the tag, the GitHub release, and upload its assets.
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Keep in sync with .github/workflows/ci.yml.
|
||||
ANDROID_PLATFORM: "platforms;android-37.0"
|
||||
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build APK and publish release
|
||||
# Mirrors ci.yml's fast jobs (assembleDebug / testDebugUnitTest / static analysis, plus
|
||||
# lintDebug) as the release prerequisite required by issue #19. ci.yml only runs on pull
|
||||
# requests, so a tag push gets no other gate. The emulator E2E matrix is deliberately NOT
|
||||
# duplicated here — it already gated every PR that reached the tagged commit.
|
||||
fast-gate:
|
||||
name: Fast CI gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
@@ -45,33 +93,448 @@ jobs:
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
# No release keystore is configured in CI, so the build falls back to the debug
|
||||
# signing key (installable for testing, not for store publication).
|
||||
- name: Assemble release APK
|
||||
run: ./gradlew assembleRelease --stacktrace
|
||||
- name: Assemble, unit-test, lint, static analysis
|
||||
run: ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:ktlintCheck :app:detekt --stacktrace
|
||||
|
||||
- name: Stage release artifacts (APK + source archives)
|
||||
build:
|
||||
name: Build and sign release artifacts
|
||||
needs: [fast-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
outputs:
|
||||
release_tag: ${{ steps.plan.outputs.release_tag }}
|
||||
version: ${{ steps.plan.outputs.version }}
|
||||
publish: ${{ steps.plan.outputs.publish }}
|
||||
signed: ${{ steps.plan.outputs.signed }}
|
||||
prerelease: ${{ steps.plan.outputs.prerelease }}
|
||||
have_play: ${{ steps.plan.outputs.have_play }}
|
||||
have_galaxy: ${{ steps.plan.outputs.have_galaxy }}
|
||||
have_s3: ${{ steps.plan.outputs.have_s3 }}
|
||||
steps:
|
||||
- name: Check out source (full history for the changelog)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
# `if:` cannot read the secrets context, so hoist secret *presence* into env here and
|
||||
# expose the resulting gates as job outputs that downstream jobs test in their `if:`.
|
||||
- name: Plan release (tag, signing, publish gates)
|
||||
id: plan
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
HAVE_SIGNING: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && secrets.RELEASE_KEYSTORE_PASSWORD != '' && secrets.RELEASE_KEY_ALIAS != '' && secrets.RELEASE_KEY_PASSWORD != '' }}
|
||||
PARTIAL_SIGNING: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' || secrets.RELEASE_KEYSTORE_PASSWORD != '' || secrets.RELEASE_KEY_ALIAS != '' || secrets.RELEASE_KEY_PASSWORD != '' }}
|
||||
HAVE_PLAY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }}
|
||||
HAVE_GALAXY: ${{ secrets.GALAXY_SERVICE_ACCOUNT_ID != '' && secrets.GALAXY_PRIVATE_KEY != '' && secrets.GALAXY_CONTENT_ID != '' }}
|
||||
HAVE_S3: ${{ secrets.ARCHIVE_S3_BUCKET != '' && secrets.ARCHIVE_S3_ACCESS_KEY_ID != '' && secrets.ARCHIVE_S3_SECRET_ACCESS_KEY != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag='${{ inputs.tag }}'
|
||||
mkdir -p dist
|
||||
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
|
||||
cp "$apk" "dist/LibreMail-${tag}.apk"
|
||||
# Source archives contain only git-tracked files at the released commit.
|
||||
git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD
|
||||
git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz"
|
||||
ls -l dist
|
||||
|
||||
- name: Create GitHub release
|
||||
tag=""
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
tag="$GITHUB_REF_NAME"
|
||||
else
|
||||
tag="$INPUT_TAG"
|
||||
fi
|
||||
|
||||
publish=false
|
||||
if [ -n "$tag" ] && [ "$INPUT_DRY_RUN" != "true" ]; then
|
||||
publish=true
|
||||
fi
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -z "$tag" ]; then
|
||||
echo "::notice::No tag input — rehearsal build only (no GitHub release, store publication or archiving)."
|
||||
elif [ "$publish" != "true" ]; then
|
||||
echo "::notice::Dry run — building and checksumming only; publication and archiving are skipped."
|
||||
fi
|
||||
|
||||
version="${tag:-dev-$(git rev-parse --short HEAD)}"
|
||||
|
||||
prerelease=false
|
||||
if [ "$HAVE_SIGNING" != "true" ]; then
|
||||
prerelease=true
|
||||
echo "::notice::Release signing secrets not configured — artifacts fall back to the debug key and are named *-unsigned (NOT store-publishable). See docs/release.md#release-signing."
|
||||
if [ "$PARTIAL_SIGNING" = "true" ]; then
|
||||
echo "::warning::Only some of the four RELEASE_* signing secrets are set; all four are required. Building unsigned."
|
||||
fi
|
||||
fi
|
||||
case "$tag" in *-*) prerelease=true ;; esac
|
||||
|
||||
if [ "$HAVE_PLAY" != "true" ]; then
|
||||
echo "::notice::Google Play publication will be skipped: secret PLAY_SERVICE_ACCOUNT_JSON is not configured. See docs/release.md#google-play."
|
||||
elif [ "$HAVE_SIGNING" != "true" ]; then
|
||||
echo "::warning::Google Play publication will be skipped: Play credentials are configured but the artifacts are unsigned (missing RELEASE_* signing secrets)."
|
||||
fi
|
||||
if [ "$HAVE_GALAXY" != "true" ]; then
|
||||
echo "::notice::Galaxy Store credentials not configured — the Galaxy job only prints the manual publication path. See docs/release.md#galaxy-store."
|
||||
fi
|
||||
if [ "$HAVE_S3" != "true" ]; then
|
||||
echo "::notice::S3 archiving will be skipped: ARCHIVE_S3_* secrets are not configured. See docs/release.md#binary-archive-s3."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "release_tag=$tag"
|
||||
echo "version=$version"
|
||||
echo "publish=$publish"
|
||||
echo "signed=$HAVE_SIGNING"
|
||||
echo "prerelease=$prerelease"
|
||||
echo "have_play=$HAVE_PLAY"
|
||||
echo "have_galaxy=$HAVE_GALAXY"
|
||||
echo "have_s3=$HAVE_S3"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Warn when the tag and versionName disagree
|
||||
if: steps.plan.outputs.release_tag != ''
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_name="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)".*/\1/p' app/build.gradle.kts | head -1)"
|
||||
expected="${RELEASE_TAG#v}"
|
||||
if [ "$version_name" != "$expected" ]; then
|
||||
echo "::warning::Tag $RELEASE_TAG does not match versionName '$version_name' in app/build.gradle.kts — did you forget to bump versionCode/versionName before tagging? (docs/release.md#cutting-a-release)"
|
||||
fi
|
||||
|
||||
# Reconstructs the git-ignored secrets.properties that app/build.gradle.kts already
|
||||
# reads for release signing, and materialises the keystore from the base64 secret.
|
||||
# Both live only on the ephemeral runner; nothing is written back to the repo.
|
||||
- name: Configure release signing from CI secrets
|
||||
if: steps.plan.outputs.signed == 'true'
|
||||
env:
|
||||
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
|
||||
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
keystore="$RUNNER_TEMP/release.keystore"
|
||||
printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > "$keystore"
|
||||
{
|
||||
printf 'RELEASE_STORE_FILE=%s\n' "$keystore"
|
||||
printf 'RELEASE_STORE_PASSWORD=%s\n' "$RELEASE_KEYSTORE_PASSWORD"
|
||||
printf 'RELEASE_KEY_ALIAS=%s\n' "$RELEASE_KEY_ALIAS"
|
||||
printf 'RELEASE_KEY_PASSWORD=%s\n' "$RELEASE_KEY_PASSWORD"
|
||||
} > secrets.properties
|
||||
echo "Release keystore configured from CI secrets."
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Install SDK platform and build-tools
|
||||
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
- name: Build release AAB and APK
|
||||
run: ./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
|
||||
|
||||
- name: Generate changelog from Conventional-Commit history
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
|
||||
VERSION: ${{ steps.plan.outputs.version }}
|
||||
SIGNED: ${{ steps.plan.outputs.signed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/whatsnew
|
||||
|
||||
# Nearest tag strictly before HEAD (HEAD itself is the release tag on tag builds).
|
||||
prev="$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || true)"
|
||||
range="HEAD"
|
||||
[ -n "$prev" ] && range="$prev..HEAD"
|
||||
echo "Changelog range: $range"
|
||||
|
||||
log() { git log --no-merges --pretty='- %s (%h)' "$range"; }
|
||||
|
||||
changelog="dist/CHANGELOG.md"
|
||||
printf '## LibreMail %s\n\n' "$VERSION" > "$changelog"
|
||||
if [ "$SIGNED" != "true" ]; then
|
||||
printf '> **Warning:** built without a release keystore — the attached binaries are debug-key signed placeholders and are **not** suitable for installation from app stores.\n\n' >> "$changelog"
|
||||
fi
|
||||
|
||||
section() { # $1 = grep -E pattern over "- subject (hash)" lines, $2 = heading
|
||||
local body
|
||||
body="$(log | grep -E "$1" || true)"
|
||||
if [ -n "$body" ]; then
|
||||
printf '### %s\n\n%s\n\n' "$2" "$body" >> "$changelog"
|
||||
fi
|
||||
}
|
||||
section '^- [a-z]+(\([^)]*\))?!:' 'Breaking changes'
|
||||
section '^- feat[(!:]' 'Features'
|
||||
section '^- fix[(!:]' 'Bug fixes'
|
||||
section '^- perf[(!:]' 'Performance'
|
||||
section '^- (docs|chore|ci|build|refactor|test|style)[(!:]' 'Maintenance'
|
||||
# Anything that is not a Conventional Commit:
|
||||
other="$(log | grep -Ev '^- (feat|fix|perf|docs|chore|ci|build|refactor|test|style)[(!:]' || true)"
|
||||
if [ -n "$other" ]; then
|
||||
printf '### Other changes\n\n%s\n\n' "$other" >> "$changelog"
|
||||
fi
|
||||
if ! log | grep -q .; then
|
||||
printf '_No changes since %s._\n\n' "${prev:-the initial commit}" >> "$changelog"
|
||||
fi
|
||||
if [ -n "$prev" ] && [ -n "$RELEASE_TAG" ]; then
|
||||
printf '**Full changelog**: https://github.com/%s/compare/%s...%s\n' "$GITHUB_REPOSITORY" "$prev" "$RELEASE_TAG" >> "$changelog"
|
||||
fi
|
||||
|
||||
# Google Play "what's new" (500-char limit): user-facing entries only.
|
||||
notes="$(log | grep -E '^- (feat|fix)[(!:]' | head -20 || true)"
|
||||
[ -z "$notes" ] && notes="- Maintenance release"
|
||||
printf 'LibreMail %s\n\n%s\n' "$VERSION" "$notes" | head -c 490 > dist/whatsnew/whatsnew-en-US
|
||||
|
||||
echo "----- CHANGELOG.md -----"
|
||||
cat "$changelog"
|
||||
|
||||
- name: Stage artifacts and compute SHA-256 checksums
|
||||
env:
|
||||
VERSION: ${{ steps.plan.outputs.version }}
|
||||
SIGNED: ${{ steps.plan.outputs.signed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
suffix=""
|
||||
[ "$SIGNED" != "true" ] && suffix="-unsigned"
|
||||
|
||||
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
|
||||
cp "$apk" "dist/LibreMail-${VERSION}${suffix}.apk"
|
||||
cp app/build/outputs/bundle/release/app-release.aab "dist/LibreMail-${VERSION}${suffix}.aab"
|
||||
# R8 mapping for de-obfuscating crash reports (isMinifyEnabled = true).
|
||||
cp app/build/outputs/mapping/release/mapping.txt "dist/LibreMail-${VERSION}-mapping.txt"
|
||||
|
||||
# Source archives with only git-tracked files at the released commit (GPL §6
|
||||
# convenience: source travels alongside every distributed binary).
|
||||
git archive --format=zip --prefix="LibreMail-${VERSION}/" -o "dist/LibreMail-${VERSION}-src.zip" HEAD
|
||||
git archive --format=tar --prefix="LibreMail-${VERSION}/" HEAD | gzip > "dist/LibreMail-${VERSION}-src.tar.gz"
|
||||
|
||||
(cd dist && sha256sum LibreMail-* > SHA256SUMS.txt)
|
||||
ls -l dist
|
||||
cat dist/SHA256SUMS.txt
|
||||
|
||||
- name: Upload release artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
github-release:
|
||||
name: Create GitHub release
|
||||
needs: [build]
|
||||
if: needs.build.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-dist
|
||||
path: dist
|
||||
|
||||
- name: Create or update the release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ inputs.tag }}
|
||||
name: ${{ inputs.tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
tag_name: ${{ needs.build.outputs.release_tag }}
|
||||
name: ${{ needs.build.outputs.release_tag }}
|
||||
body_path: dist/CHANGELOG.md
|
||||
prerelease: ${{ needs.build.outputs.prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
dist/LibreMail-${{ inputs.tag }}.apk
|
||||
dist/LibreMail-${{ inputs.tag }}-src.zip
|
||||
dist/LibreMail-${{ inputs.tag }}-src.tar.gz
|
||||
dist/LibreMail-*
|
||||
dist/SHA256SUMS.txt
|
||||
|
||||
google-play:
|
||||
name: Publish to Google Play
|
||||
needs: [build]
|
||||
# Requires publishable (release-signed) artifacts AND Play credentials; the build job's
|
||||
# plan step logs a notice/warning explaining any skip.
|
||||
if: needs.build.outputs.publish == 'true' && needs.build.outputs.signed == 'true' && needs.build.outputs.have_play == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-dist
|
||||
path: dist
|
||||
|
||||
- name: Determine track and staged-rollout mode
|
||||
id: mode
|
||||
env:
|
||||
TRACK: ${{ inputs.play_track || 'internal' }}
|
||||
FRACTION: ${{ inputs.play_rollout_fraction || '0.10' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="completed"
|
||||
fraction=""
|
||||
if [ "$TRACK" = "production" ]; then
|
||||
case "$FRACTION" in
|
||||
0 | 0.0 | 0.00)
|
||||
echo "::error::play_rollout_fraction must be greater than 0 (got '$FRACTION')."
|
||||
exit 1
|
||||
;;
|
||||
1 | 1.0 | 1.00)
|
||||
status="completed" # full rollout
|
||||
;;
|
||||
0.[0-9]*)
|
||||
status="inProgress" # staged rollout
|
||||
fraction="$FRACTION"
|
||||
;;
|
||||
*)
|
||||
echo "::error::play_rollout_fraction must be a fraction like 0.10 (0 < f < 1) or 1.0 for a full rollout (got '$FRACTION')."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo "Publishing to track '$TRACK' with status '$status'${fraction:+ (user fraction $fraction)}."
|
||||
{
|
||||
echo "track=$TRACK"
|
||||
echo "status=$status"
|
||||
echo "fraction=$fraction"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload to Google Play
|
||||
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: org.libremail.app
|
||||
releaseFiles: dist/LibreMail-*.aab
|
||||
track: ${{ steps.mode.outputs.track }}
|
||||
status: ${{ steps.mode.outputs.status }}
|
||||
userFraction: ${{ steps.mode.outputs.fraction }}
|
||||
whatsNewDirectory: dist/whatsnew
|
||||
mappingFile: dist/LibreMail-${{ needs.build.outputs.version }}-mapping.txt
|
||||
|
||||
# Samsung provides no maintained GitHub Action, and its Content Publish API is mid-
|
||||
# migration (contentUpdate's binaryList parameter stops being accepted in July 2026), so
|
||||
# automated submission is deliberately stubbed until #17 lands store credentials and the
|
||||
# API settles. This job documents the state and the manual path; docs/release.md#galaxy-store
|
||||
# has the full instructions. The GALAXY_* secret names are reserved for the future wiring.
|
||||
galaxy-store:
|
||||
name: Publish to Galaxy Store (manual for now)
|
||||
needs: [build]
|
||||
if: needs.build.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Explain the Galaxy Store publication path
|
||||
env:
|
||||
HAVE_GALAXY: ${{ needs.build.outputs.have_galaxy }}
|
||||
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$HAVE_GALAXY" = "true" ]; then
|
||||
echo "::notice::GALAXY_* secrets are configured, but automated Galaxy Store submission is intentionally disabled: Samsung ships no maintained GitHub Action and its Content Publish API is mid-migration (binaryList removal, July 2026). Publish manually for now — see docs/release.md#galaxy-store."
|
||||
else
|
||||
echo "::notice::Galaxy Store credentials (GALAXY_SERVICE_ACCOUNT_ID / GALAXY_PRIVATE_KEY / GALAXY_CONTENT_ID) are not configured — publish manually. See docs/release.md#galaxy-store."
|
||||
fi
|
||||
cat <<EOF
|
||||
Manual Galaxy Store publication for $RELEASE_TAG:
|
||||
1. Download LibreMail-$RELEASE_TAG.apk (and SHA256SUMS.txt) from the GitHub release.
|
||||
2. Verify the checksum: sha256sum -c SHA256SUMS.txt
|
||||
3. Sign in to Samsung Seller Portal (https://seller.samsungapps.com), open the
|
||||
LibreMail app entry, upload the APK as a new binary, update the release notes
|
||||
from CHANGELOG.md and submit for review.
|
||||
EOF
|
||||
|
||||
s3-archive:
|
||||
name: Archive binaries to S3
|
||||
needs: [build]
|
||||
if: needs.build.outputs.publish == 'true' && needs.build.outputs.have_s3 == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-dist
|
||||
path: dist
|
||||
|
||||
# Uses the AWS CLI preinstalled on ubuntu runners; --endpoint-url makes it work with
|
||||
# any S3-compatible provider (MinIO, Backblaze B2, Cloudflare R2, …). Keys are
|
||||
# versioned per release under releases/<tag>/, with SHA256SUMS.txt stored alongside.
|
||||
- name: Upload artifacts and checksums
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ARCHIVE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARCHIVE_S3_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.ARCHIVE_S3_REGION || 'us-east-1' }}
|
||||
S3_BUCKET: ${{ secrets.ARCHIVE_S3_BUCKET }}
|
||||
S3_ENDPOINT: ${{ secrets.ARCHIVE_S3_ENDPOINT }}
|
||||
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint_args=()
|
||||
[ -n "$S3_ENDPOINT" ] && endpoint_args+=(--endpoint-url "$S3_ENDPOINT")
|
||||
dest="s3://${S3_BUCKET}/releases/${RELEASE_TAG}/"
|
||||
aws s3 cp dist/ "$dest" --recursive --exclude "whatsnew/*" "${endpoint_args[@]}"
|
||||
echo "Archived release artifacts to $dest:"
|
||||
aws s3 ls "$dest" "${endpoint_args[@]}"
|
||||
|
||||
# Single aggregating result (mirrors ci.yml's ci-passed): fails if any stage failed, and
|
||||
# writes a per-stage summary — including why gated stages were skipped — to the run page.
|
||||
release-summary:
|
||||
name: Release summary
|
||||
needs: [fast-gate, build, github-release, google-play, galaxy-store, s3-archive]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Write summary
|
||||
env:
|
||||
R_GATE: ${{ needs.fast-gate.result }}
|
||||
R_BUILD: ${{ needs.build.result }}
|
||||
R_RELEASE: ${{ needs.github-release.result }}
|
||||
R_PLAY: ${{ needs.google-play.result }}
|
||||
R_GALAXY: ${{ needs.galaxy-store.result }}
|
||||
R_S3: ${{ needs.s3-archive.result }}
|
||||
O_TAG: ${{ needs.build.outputs.release_tag }}
|
||||
O_PUBLISH: ${{ needs.build.outputs.publish }}
|
||||
O_SIGNED: ${{ needs.build.outputs.signed }}
|
||||
O_PLAY: ${{ needs.build.outputs.have_play }}
|
||||
O_S3: ${{ needs.build.outputs.have_s3 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
note_release=""
|
||||
if [ "$O_PUBLISH" != "true" ]; then note_release="dry run / rehearsal — nothing published"; fi
|
||||
note_play=""
|
||||
if [ "$O_PLAY" != "true" ]; then
|
||||
note_play="needs PLAY_SERVICE_ACCOUNT_JSON"
|
||||
elif [ "$O_SIGNED" != "true" ]; then
|
||||
note_play="unsigned build — needs RELEASE_* signing secrets"
|
||||
fi
|
||||
note_s3=""
|
||||
if [ "$O_S3" != "true" ]; then note_s3="needs ARCHIVE_S3_* secrets"; fi
|
||||
{
|
||||
echo "## Release pipeline — ${O_TAG:-rehearsal (no tag)}"
|
||||
echo
|
||||
echo "| Stage | Result | Notes |"
|
||||
echo "| --- | --- | --- |"
|
||||
echo "| Fast CI gate | $R_GATE | |"
|
||||
echo "| Build + sign | $R_BUILD | signed: ${O_SIGNED:-n/a} |"
|
||||
echo "| GitHub release | $R_RELEASE | $note_release |"
|
||||
echo "| Google Play | $R_PLAY | $note_play |"
|
||||
echo "| Galaxy Store | $R_GALAXY | manual for now — docs/release.md#galaxy-store |"
|
||||
echo "| S3 archive | $R_S3 | $note_s3 |"
|
||||
echo
|
||||
echo "Secret setup: docs/release.md"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Fail if any stage failed
|
||||
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
run: |
|
||||
echo "A release stage failed or was cancelled:"
|
||||
echo " fast-gate: ${{ needs.fast-gate.result }}"
|
||||
echo " build: ${{ needs.build.result }}"
|
||||
echo " github-release: ${{ needs.github-release.result }}"
|
||||
echo " google-play: ${{ needs.google-play.result }}"
|
||||
echo " galaxy-store: ${{ needs.galaxy-store.result }}"
|
||||
echo " s3-archive: ${{ needs.s3-archive.result }}"
|
||||
exit 1
|
||||
|
||||
@@ -41,3 +41,6 @@ captures/
|
||||
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# Claude Code — personal settings (the shared settings.json IS committed)
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
LibreMail is a GPL-3.0 Android email client (Kotlin, Jetpack Compose, Material 3).
|
||||
See `@README.md` for OAuth/account setup (Gmail, Outlook), the tech stack, and the
|
||||
offline-first architecture — this file only covers what isn't obvious from the code.
|
||||
|
||||
## Build, test, lint
|
||||
|
||||
Use a **JDK 17–21** for the Gradle daemon. AGP 9.2 does **not** support JDK 25 — if
|
||||
`JAVA_HOME` points at 25+, builds fail. Commands (PowerShell: use `.\gradlew`):
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug # build debug APK
|
||||
./gradlew :app:testDebugUnitTest # JVM unit tests
|
||||
./gradlew :app:lintDebug # Android lint
|
||||
./gradlew :app:ktlintCheck :app:detekt # static analysis (CI's "Static analysis" gate)
|
||||
# single unit test:
|
||||
./gradlew :app:testDebugUnitTest --tests "org.libremail.data.SomeClassTest"
|
||||
```
|
||||
|
||||
E2E/instrumented tests need a booted emulator: `./gradlew :app:connectedDebugAndroidTest`,
|
||||
or via Gradle Managed Devices `./gradlew e2eGroupDebugAndroidTest` (whole matrix) /
|
||||
`./gradlew api29DebugAndroidTest` (one API level). The managed-device list in
|
||||
`app/build.gradle.kts` must stay in lockstep with the E2E matrix in `.github/workflows/ci.yml`.
|
||||
|
||||
**Before treating a change as done**, run the fast CI gate: `assembleDebug` +
|
||||
`testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` (the `/preflight` skill does
|
||||
this). `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that `lintDebug`
|
||||
skips, so they catch style violations that would otherwise fail CI's Static analysis gate.
|
||||
Emulator E2E is left to CI unless asked.
|
||||
|
||||
## Build-config gotchas
|
||||
|
||||
- **Built-in Kotlin (AGP 9.x).** Kotlin compilation is handled by AGP's built-in Kotlin;
|
||||
the Kotlin version (2.4.0) is pinned via the root `build.gradle.kts` buildscript classpath.
|
||||
**Never apply the `org.jetbrains.kotlin.android` plugin** — it throws a ClassCastException
|
||||
against AGP 9's DSL. (The `kotlin-android` alias in `libs.versions.toml` exists but must
|
||||
not be used.) `libs.versions.toml` still supplies all *library* versions.
|
||||
- **KSP, not KAPT** for all annotation processing (Hilt, Room).
|
||||
- Room schemas are exported to `app/schemas` and validated by migration tests — commit
|
||||
schema changes.
|
||||
- OAuth client IDs come from `secrets.properties` (git-ignored) via `BuildConfig`; the
|
||||
build works without it (empty/placeholder values).
|
||||
|
||||
## Code conventions
|
||||
|
||||
- Sources live under `app/src/{main,test,androidTest}/kotlin/`; package root `org.libremail`
|
||||
(applicationId `org.libremail.app`).
|
||||
- **Every source file starts with** `// SPDX-License-Identifier: GPL-3.0-or-later` (or the
|
||||
`<!-- ... -->` form for XML/Markdown). All 117 current `.kt` files follow this.
|
||||
- `kotlin.code.style=official`.
|
||||
|
||||
## Testing
|
||||
|
||||
JVM unit tests use JUnit4 + `kotlin.test`, **Turbine** for `Flow`, **MockK** for mocks,
|
||||
**GreenMail** for a real in-process IMAP/SMTP server, and coroutines-test. `org.json` is
|
||||
pulled in as a real dependency for unit tests because `android.jar`'s version is a no-op stub.
|
||||
|
||||
## Repo etiquette
|
||||
|
||||
- Branch off `main`; branch names like `feat-…` / `fix-…`. PRs target `main` and must pass
|
||||
the `CI passed` gate.
|
||||
- **Conventional Commits** for commit subjects and PR titles: `type(scope): summary`
|
||||
(`feat`, `fix`, `chore`, …), matching existing history.
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# LibreMail Privacy Policy
|
||||
|
||||
**Effective date: 2026-07-01** · Applies to the LibreMail Android app (`org.libremail.app`).
|
||||
|
||||
LibreMail is a free and open-source (GPL-3.0-or-later) email client. This policy describes what
|
||||
the app does with your data. Because the source code is public, every statement here can be
|
||||
verified against the code at <https://github.com/JMR-dev/LibreMail>.
|
||||
|
||||
## Summary
|
||||
|
||||
- **We run no servers and receive no data from you.** The LibreMail project has no backend: the
|
||||
app talks only to the email provider(s) *you* configure (e.g. your Gmail, Outlook, Yahoo,
|
||||
iCloud, or self-hosted IMAP/SMTP server) and, for Outlook accounts, to Microsoft's sign-in and
|
||||
Graph endpoints.
|
||||
- **Your mail stays on your device.** Messages are cached locally so the app works offline; the
|
||||
cache can optionally be encrypted at rest.
|
||||
- **No ads, no analytics, no tracking.** The app contains no advertising, analytics, or tracking
|
||||
SDK of any kind, and no Google Play Services or Firebase dependency.
|
||||
- **Nothing is sent to the developers** — including crash reports, which are strictly opt-in,
|
||||
stored locally, shown to you for review, and (in this build) cannot be uploaded at all because
|
||||
no ingest endpoint is configured.
|
||||
|
||||
## What the app stores on your device
|
||||
|
||||
All of the following lives in the app's private storage on your device only:
|
||||
|
||||
- **Account settings** — your email address, display name, and server host/port/security
|
||||
settings for each account you add.
|
||||
- **Credentials** — your per-account app password or OAuth tokens, encrypted with a hardware-
|
||||
backed key in the Android Keystore before being written to storage.
|
||||
- **Mail cache** — headers, message bodies, and folder state, in a local database so your mail is
|
||||
available offline. You can optionally enable **cache encryption** (SQLCipher) in Settings; the
|
||||
database key is random, never leaves the device, and is itself sealed by the Android Keystore.
|
||||
- **Attachments** you download or attach, in the app's cache directory (Android may clear this
|
||||
automatically to reclaim space).
|
||||
- **Preferences** — theme, notification, sync, and privacy toggles.
|
||||
- **Debug reports** — only if a crash occurs or you ask the app to capture one; see
|
||||
[Diagnostics](#diagnostics-and-debug-reports).
|
||||
|
||||
Uninstalling the app, or clearing its storage in Android settings, deletes all of the above.
|
||||
|
||||
## What leaves your device
|
||||
|
||||
The app makes network connections **only** to servers that operate your email service:
|
||||
|
||||
- **Your mail servers** — the IMAP and SMTP hosts of each account you configure (for the built-in
|
||||
presets: `imap/smtp.gmail.com`, `imap/smtp.mail.yahoo.com`, `imap/smtp.mail.me.com`;
|
||||
`outlook.office.com` for Outlook). This traffic is your email itself: signing in, downloading
|
||||
your mail, sending the messages you write, and — when you use server search — your search
|
||||
query. That is the app doing its job as your email client; none of it goes to us.
|
||||
- **Microsoft identity platform and Graph** (`login.microsoftonline.com`,
|
||||
`graph.microsoft.com`) — only for Outlook/Hotmail accounts, to sign you in with OAuth 2.0 and
|
||||
to send mail via Microsoft's API.
|
||||
- **Remote images in emails** — blocked by default. If you enable "load remote images", the
|
||||
message viewer will fetch images from the servers referenced by the email (which can reveal
|
||||
your IP address to the sender), so it stays off unless you turn it on.
|
||||
|
||||
Every mail connection uses TLS (SSL/TLS or STARTTLS) with server-certificate hostname
|
||||
verification; the account-setup UI does not offer an unencrypted option.
|
||||
|
||||
The app never transmits your data to the LibreMail project or any third party of ours. There is
|
||||
no telemetry, no "phone home", and no ad or analytics traffic.
|
||||
|
||||
## Contacts (`READ_CONTACTS` permission)
|
||||
|
||||
When composing a message, LibreMail can suggest recipients from your device contacts. The app
|
||||
asks for the contacts permission the first time you open the compose screen:
|
||||
|
||||
- Contact lookups run **entirely on the device** and return at most a handful of name/email
|
||||
matches for what you typed. Your contact list is never uploaded, copied, or synced anywhere.
|
||||
- The only way a contact detail leaves the device is when *you* put an address in an email you
|
||||
send — it then appears in that email, like in any mail client.
|
||||
- The permission is optional: if you deny it, autocomplete is silently disabled and everything
|
||||
else keeps working.
|
||||
|
||||
## Notifications (`POST_NOTIFICATIONS` permission)
|
||||
|
||||
Used to show new-mail notifications (per-account, with sender/subject hidden on a locked screen)
|
||||
and the persistent low-priority status notification Android requires while the optional
|
||||
instant-push connection is active. New-mail notifications are generated **on the device** from
|
||||
your synced mail — there is no push server and no cloud messaging service involved. You can
|
||||
decline the permission or disable notifications per account in system settings.
|
||||
|
||||
## Instant push (foreground service)
|
||||
|
||||
For instant mail delivery the app can hold an open IMAP IDLE connection to your mail server in a
|
||||
foreground service (shown as a persistent notification). This connects only to your own mail
|
||||
server, and can be turned off in Settings ("push mail"), which falls back to periodic background
|
||||
sync.
|
||||
|
||||
## Diagnostics and debug reports
|
||||
|
||||
LibreMail has **no automatic crash or usage reporting**. What exists instead:
|
||||
|
||||
- If the app crashes, or you use "Report a problem", a report is saved **locally** on your
|
||||
device. It contains the app version, Android version, device make/model, a stack trace (for
|
||||
crashes), a short summary of non-identifying settings, and recent internal log lines — by
|
||||
design no account addresses, server names, or message content fields are collected.
|
||||
- You can view the full report text (with a plain-language notice to check it for anything
|
||||
personal), copy it, share it yourself, or delete it. It is transmitted **only** if you
|
||||
explicitly tap Submit — never in the background.
|
||||
- In the builds produced from this repository **no upload endpoint is configured**, so even an
|
||||
explicit Submit cannot send anything; the report simply stays on your device. If a future
|
||||
release adds an endpoint, submission will remain strictly opt-in and user-initiated, and this
|
||||
policy will be updated.
|
||||
|
||||
## Android Backup
|
||||
|
||||
Android's cloud backup is **off by default** for LibreMail. If you enable "Include settings in
|
||||
Android Backup" in Settings, only your app preferences are backed up through your device's
|
||||
Android Backup transport (typically Google's). Your credentials, the mail cache, and the cache
|
||||
encryption key are always excluded from backups.
|
||||
|
||||
## Data deletion
|
||||
|
||||
- **Remove an account** (in the app's account settings) — deletes that account's stored
|
||||
credentials, its cached messages, folders, and per-account settings from the local database,
|
||||
and its notification channels. Copies of downloaded attachments in the app's cache directory
|
||||
are cleared by Android's normal cache management, or immediately via "Clear cache" in system
|
||||
settings.
|
||||
- **Uninstall the app / clear storage** — removes all locally stored app data.
|
||||
- **Your mailbox is unaffected**: mail lives with your email provider; deleting data in
|
||||
LibreMail does not delete mail from the server unless you explicitly delete messages in the
|
||||
app. We hold no copy of your data, so there is nothing for us to delete on any server.
|
||||
|
||||
## Children
|
||||
|
||||
LibreMail is a general-audience utility that requires an existing email account. It is not
|
||||
directed at children, and — as described above — it collects no data from any user.
|
||||
|
||||
## Changes and contact
|
||||
|
||||
Changes to this policy are made in the public repository with full version history. Questions or
|
||||
concerns: open an issue at <https://github.com/JMR-dev/LibreMail/issues>.
|
||||
@@ -5,28 +5,37 @@ A free and open-source email client for Android, built with Kotlin, Jetpack
|
||||
Compose and Material 3 (Material You). LibreMail aims for a friendly default
|
||||
experience with power-user features tucked under an **Advanced Settings** group.
|
||||
|
||||
> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via
|
||||
> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore-
|
||||
> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local
|
||||
> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and
|
||||
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
|
||||
> with mark-read, star, and delete; and **composing** — a compose screen with device-
|
||||
> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued
|
||||
> and retried, with a viewable outbox folder), plus reply and **drafts** saved for
|
||||
> later; **on-device new-mail
|
||||
> notifications** (no push service) with persisted settings; **instant push** via a
|
||||
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
|
||||
> system viewer, and attach files when composing; **multiple accounts** — a unified inbox
|
||||
> with per-account filtering; and
|
||||
> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft**
|
||||
> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback).
|
||||
> Status: **in development.** Material You shell; **onboarding** — a first-run flow from a
|
||||
> welcome screen through a vendor picker (Outlook/Hotmail, Gmail, Yahoo, iCloud, or Other) and
|
||||
> per-vendor setup to your first account's inbox; **account setup** — Outlook/Microsoft via
|
||||
> OAuth 2.0 (AppAuth/PKCE), Gmail/Yahoo/iCloud via app password, and generic IMAP/SMTP, all
|
||||
> with a live connection test and Keystore-encrypted credentials; **IMAP receive** — background
|
||||
> sync (WorkManager) into a local Room cache with pull-to-refresh, backfilling your **entire**
|
||||
> mail history (resumable) with an optional device-only retention cap; **reading** — message
|
||||
> bodies fetched on open and rendered in a hardened WebView (JavaScript off, remote images
|
||||
> blocked by default), with mark-read, star, and delete; **composing** — a rich-text HTML
|
||||
> editor with a formatting toolbar and per-account signatures that sends
|
||||
> `multipart/alternative` (HTML with a plaintext fallback) through a reliable background
|
||||
> **outbox** (WorkManager-queued and retried, with a viewable outbox folder), plus
|
||||
> device-contacts autocomplete, reply, and **drafts**; **on-device new-mail notifications** (no
|
||||
> push service) with persisted settings; **instant push** via a foreground **IMAP IDLE**
|
||||
> service; **attachments** — downloaded on demand and opened in a system viewer, and attach
|
||||
> files when composing; **multiple accounts** — a unified inbox with per-account filtering;
|
||||
> **search** across cached mail and the server (IMAP SEARCH); Outlook/Microsoft send via
|
||||
> Microsoft **Graph** with an SMTP/XOAUTH2 fallback; an opt-in **app lock**
|
||||
> (biometric/device-credential) that binds the encrypted cache key to your unlock; **mailto:**
|
||||
> link handling with optional default-mail-app registration; and opt-in, F-Droid-safe **debug
|
||||
> reporting** — local crash/error capture that you review (with a PII disclaimer) and submit
|
||||
> only on an explicit action.
|
||||
|
||||
## Features (target MVP)
|
||||
|
||||
- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
|
||||
- Send and receive email with **Outlook/Microsoft** (OAuth 2.0), **Gmail, Yahoo and iCloud** (app password), and **any other IMAP/SMTP** provider.
|
||||
- Guided first-run onboarding: welcome → vendor picker → per-vendor setup → your inbox.
|
||||
- Material You dynamic theming, light/dark, edge-to-edge.
|
||||
- Clean compose screen with phone/account contacts integration.
|
||||
- Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail.
|
||||
- Rich-text compose with a formatting toolbar, per-account signatures, and phone/account contacts integration.
|
||||
- Offline-first: a local Room cache with full-history backfill and an optional device-only retention limit.
|
||||
- Modern, opt-in security: OAuth 2.0 (Authorization Code + PKCE) for Outlook, Keystore-encrypted credentials, optional SQLCipher cache encryption, and a biometric/device-credential app lock.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -67,27 +76,27 @@ sdkmanager "platforms;android-37.0" "build-tools;37.0.0"
|
||||
`local.properties` (git-ignored) must point `sdk.dir` at your Android SDK; Android
|
||||
Studio creates it automatically.
|
||||
|
||||
## Gmail account setup (OAuth client)
|
||||
## Accounts and onboarding
|
||||
|
||||
Gmail IMAP/SMTP requires the restricted `https://mail.google.com/` scope. While the
|
||||
app is unpublished you can use it in **Testing** mode with up to 100 test users and
|
||||
no security assessment; a public Play Store release later requires a Google CASA
|
||||
assessment for the restricted scope.
|
||||
On first launch LibreMail runs a short onboarding flow: a welcome screen, a **vendor picker**
|
||||
(Outlook/Hotmail, Gmail, Yahoo, iCloud, or **Other**), per-vendor setup, and an "add another
|
||||
account?" prompt before it drops you on your first account's inbox. You can add more accounts
|
||||
later from settings; a unified inbox merges them with per-account filtering.
|
||||
|
||||
1. In the [Google Cloud Console](https://console.cloud.google.com/), create a
|
||||
project (e.g. *LibreMail*).
|
||||
2. **APIs & Services → Library →** enable the **Gmail API**.
|
||||
3. **OAuth consent screen:** user type *External*; add the scope
|
||||
`https://mail.google.com/`; under **Test users**, add your Google address.
|
||||
Leave the app in **Testing**.
|
||||
4. **Credentials → Create credentials → OAuth client ID → Android.** Use package
|
||||
name `org.libremail.app` and your debug keystore SHA-1:
|
||||
```bash
|
||||
keytool -list -v -keystore "$HOME/.android/debug.keystore" \
|
||||
-alias androiddebugkey -storepass android -keypass android
|
||||
```
|
||||
5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set
|
||||
`GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`.
|
||||
LibreMail supports three kinds of account:
|
||||
|
||||
- **Outlook / Hotmail** — signs in with **OAuth 2.0** through Microsoft (AppAuth); no password
|
||||
is stored. See [Outlook / Microsoft account setup](#outlook--microsoft-account-setup-oauth-client)
|
||||
below.
|
||||
- **Gmail, Yahoo and iCloud** — preconfigured IMAP/SMTP that authenticate with a provider
|
||||
**app password** (not your normal account password), preferring STARTTLS where the provider
|
||||
supports it. Onboarding links you to each vendor's app-password page. **Gmail requires
|
||||
2-Step Verification to be enabled** before Google will issue an app password.
|
||||
- **Other** — a manual IMAP/SMTP form (host, port, security, and credentials) for any other
|
||||
provider.
|
||||
|
||||
App passwords and OAuth tokens are held in a credential store encrypted with the Android
|
||||
Keystore, and every account runs a live connection test before it is saved.
|
||||
|
||||
## Outlook / Microsoft account setup (OAuth client)
|
||||
|
||||
@@ -108,6 +117,28 @@ token. A working client ID ships with the build; to use your own Azure app regis
|
||||
4. Copy the **Application (client) ID** into `secrets.properties` as
|
||||
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
|
||||
|
||||
## Privacy and data flow
|
||||
|
||||
LibreMail is offline-first: your mail lives in a local cache, and by default network traffic
|
||||
goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for
|
||||
Outlook). There is no analytics SDK and no always-on telemetry. The full privacy policy lives in
|
||||
[`PRIVACY.md`](PRIVACY.md); Google Play compliance notes (data-safety mapping, permissions
|
||||
justification) are under [`docs/`](docs/). Because Gmail uses an app password (no Google OAuth
|
||||
scopes), no Google restricted-scope verification or CASA assessment applies; Outlook's OAuth
|
||||
client is governed by Microsoft's Azure rules. The privacy-sensitive extras are all **opt-in**:
|
||||
|
||||
- **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the
|
||||
optional **app lock** (biometric or device credential) enabled, the cache key is bound to
|
||||
your authentication, so the database is only decrypted after you unlock the app.
|
||||
- **Debug reporting** — **off by default.** When enabled, crashes and errors are captured
|
||||
**locally**; you review the full report — shown with a plain-language **PII disclaimer** —
|
||||
and it is sent only when you explicitly submit it, to a configurable (optional) endpoint.
|
||||
There is no hosted crash pipeline collecting reports in the background.
|
||||
- **Android Backup** — **off by default.** When you turn it on, only safe app settings are
|
||||
backed up; the encrypted-database key, account credentials, and the mail cache are
|
||||
**excluded**. Because Android's backup transport can route data through Google, it stays
|
||||
disabled unless you opt in — the kind of optional behavior F-Droid lists as an anti-feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
Offline-first, unidirectional, layered:
|
||||
@@ -119,11 +150,31 @@ data/ Room (entities, DAOs, database) + repository implementation (source o
|
||||
di/ Hilt modules
|
||||
```
|
||||
|
||||
The UI observes Room via `Flow`; later increments add a sync engine (Angus Mail
|
||||
over IMAP/SMTP) that writes into Room, and an auth layer (AppAuth + an Android
|
||||
Keystore-backed credential store).
|
||||
The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus Microsoft
|
||||
Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android
|
||||
Keystore-backed credential store for app passwords) handles sign-in.
|
||||
|
||||
## F-Droid
|
||||
|
||||
LibreMail is built to meet F-Droid's inclusion criteria: every dependency is
|
||||
FOSS-licensed, there are no Google Play Services / Firebase / proprietary SDKs, the
|
||||
build needs no `secrets.properties`, and there are **no anti-features to declare**
|
||||
(the privacy-sensitive extras above are all opt-in). The full dependency license
|
||||
audit, anti-feature review, and clean-room build verification live in
|
||||
[`docs/fdroid-compliance.md`](docs/fdroid-compliance.md); the store listing is under
|
||||
[`fastlane/metadata/android/`](fastlane/metadata/android/en-US), and
|
||||
[`docs/fdroid/org.libremail.app.yml`](docs/fdroid/org.libremail.app.yml) is the
|
||||
template for the eventual fdroiddata build recipe.
|
||||
|
||||
## License
|
||||
|
||||
LibreMail is licensed under the **GNU General Public License v3.0** — see
|
||||
[`LICENSE`](LICENSE). SPDX identifier: `GPL-3.0-or-later`.
|
||||
|
||||
Onboarding requires agreeing to this license before anything else (#172); the screen that shows it
|
||||
(`ui/onboarding/LicenseScreen.kt`) reads a bundled runtime copy at
|
||||
`app/src/main/res/raw/license.txt` rather than this file directly. That copy is a byte-for-byte
|
||||
duplicate, kept in sync by hand rather than generated at build time or annotated in place (the
|
||||
in-app screen renders it verbatim, so any header/footer added to the copy would show up to the user
|
||||
as if it were part of the license) — **if you edit `LICENSE`, copy the change into `license.txt`
|
||||
too**, and vice versa.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+88
-18
@@ -9,23 +9,17 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.google.dagger.hilt.android")
|
||||
// Lint/format — resolved from the Gradle Plugin Portal (not the buildscript classpath).
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.detekt)
|
||||
}
|
||||
|
||||
// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent.
|
||||
// Read optional build secrets (Outlook client id, release signing) from secrets.properties
|
||||
// (git-ignored). Absent values fall back to the defaults below.
|
||||
val secretsFile = rootProject.file("secrets.properties")
|
||||
val secrets = Properties().apply {
|
||||
if (secretsFile.exists()) secretsFile.inputStream().use { load(it) }
|
||||
}
|
||||
val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "")
|
||||
|
||||
// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client
|
||||
// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid
|
||||
// until a real client id is set in secrets.properties.
|
||||
val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) {
|
||||
"com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com")
|
||||
} else {
|
||||
"org.libremail.oauth"
|
||||
}
|
||||
|
||||
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
|
||||
// secrets.properties; defaults to the app's registered client id.
|
||||
@@ -34,6 +28,15 @@ val outlookOAuthClientId: String = secrets.getProperty(
|
||||
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
|
||||
)
|
||||
|
||||
// Custom URI scheme AppAuth uses to capture the Outlook OAuth redirect. Must match the scheme of
|
||||
// OUTLOOK_OAUTH_REDIRECT_URI and the redirect URI registered in the Azure app registration.
|
||||
val outlookRedirectScheme = "org.libremail.outlook"
|
||||
|
||||
// Debug-report ingest endpoint (issue #34, out of scope for this repo). Empty by default: the debug
|
||||
// reporting client is strictly opt-in and never sends anything unless the user taps Submit AND an
|
||||
// endpoint is configured here (overridable via git-ignored secrets.properties).
|
||||
val debugReportEndpoint: String = secrets.getProperty("DEBUG_REPORT_ENDPOINT", "")
|
||||
|
||||
// Optional release signing, configured via git-ignored secrets.properties. When absent, release
|
||||
// builds fall back to the debug key (installable for testing, but not publishable).
|
||||
val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE")
|
||||
@@ -44,19 +47,20 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.libremail.app"
|
||||
minSdk = 33
|
||||
// Supports a rolling ~7-year window of Android versions (API 29 / Android 10, 2019 → latest).
|
||||
minSdk = 29
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
|
||||
buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"")
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"")
|
||||
// AppAuth captures the OAuth redirect via this custom scheme.
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"")
|
||||
buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"")
|
||||
// AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on
|
||||
// RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app.
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -92,6 +96,22 @@ android {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them.
|
||||
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||
|
||||
// F-Droid compliance (issue #16): by default AGP embeds a "dependency info block" in the APK
|
||||
// signing block — a list of every dependency, encrypted so that ONLY Google Play can read it.
|
||||
// F-Droid's inclusion policy treats that opaque, Google-only blob as a blocker (it cannot be
|
||||
// verified from source and breaks reproducible builds), so keep it out of APKs and bundles.
|
||||
// See docs/fdroid-compliance.md.
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
// Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them.
|
||||
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries.
|
||||
@@ -104,13 +124,46 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
// Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual
|
||||
// device per supported Android version (a rolling ~7-year window, API 29 → latest stable).
|
||||
// Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g.
|
||||
// `./gradlew api29DebugAndroidTest`. Gradle provisions/boots/tears down the emulators and
|
||||
// downloads the system images on first use. Keep this list in lockstep with the CI matrix in
|
||||
// .github/workflows/ci.yml; when a new Android ships, add it and drop the oldest level that
|
||||
// has fallen outside ~7 years. API 37 (preview) is exercised on the dev emulator until a
|
||||
// stable managed-device image is published, so it is intentionally not listed here.
|
||||
managedDevices {
|
||||
localDevices {
|
||||
listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api ->
|
||||
create("api$api") {
|
||||
device = "Pixel 2"
|
||||
apiLevel = api
|
||||
systemImageSource = "google_apis"
|
||||
}
|
||||
}
|
||||
}
|
||||
groups {
|
||||
create("e2e") {
|
||||
targetDevices.addAll(localDevices)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests.
|
||||
// Export Room schemas so the instrumented MigrationTest can replay and validate each migration.
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
detekt {
|
||||
// Merge the project overrides in config/detekt onto detekt's bundled defaults.
|
||||
buildUponDefaultConfig = true
|
||||
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
@@ -118,6 +171,8 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Email transport (IMAP/SMTP) + OAuth
|
||||
@@ -145,20 +200,35 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.room.paging)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.sqlcipher.android)
|
||||
|
||||
// Paging 3 — the unified inbox list is paged so its cost scales with the screen (issue #124).
|
||||
implementation(libs.androidx.paging.runtime)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
// Raise kotlinx-serialization to the version Room's schema-bundle serializers were compiled
|
||||
// against (see libs.versions.toml). AGP 9 consistent resolution shares it with the androidTest
|
||||
// classpath so MigrationTestHelper can parse the exported schema JSON.
|
||||
implementation(platform(libs.kotlinx.serialization.bom))
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.turbine)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.greenmail)
|
||||
// asSnapshot() drives a PagingData flow to a concrete list in JVM unit tests (issue #124).
|
||||
testImplementation(libs.androidx.paging.testing)
|
||||
// The real org.json for unit tests (android.jar ships a stubbed, no-op version).
|
||||
testImplementation("org.json:json:20231013")
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.espresso.intents)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "f2bbe80e572de72f50869b14aca4c4bb",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionCount",
|
||||
"columnName": "retentionCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionMonths",
|
||||
"columnName": "retentionMonths",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f2bbe80e572de72f50869b14aca4c4bb')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 10,
|
||||
"identityHash": "aff2afe197ff1b0b41630e5a165d09f3",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aff2afe197ff1b0b41630e5a165d09f3')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 11,
|
||||
"identityHash": "a31c7e2934217228a837ab5453025da4",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a31c7e2934217228a837ab5453025da4')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 12,
|
||||
"identityHash": "45329c820e9325adbfd6b7de89a9996a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '45329c820e9325adbfd6b7de89a9996a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 13,
|
||||
"identityHash": "e4f7ef1e0d780324d6eec3efced768ae",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionCount",
|
||||
"columnName": "retentionCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionMonths",
|
||||
"columnName": "retentionMonths",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f7ef1e0d780324d6eec3efced768ae')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 14,
|
||||
"identityHash": "e4f7ef1e0d780324d6eec3efced768ae",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionCount",
|
||||
"columnName": "retentionCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionMonths",
|
||||
"columnName": "retentionMonths",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f7ef1e0d780324d6eec3efced768ae')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 15,
|
||||
"identityHash": "02427d9d822cbb18a9bcd0670c048585",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionCount",
|
||||
"columnName": "retentionCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionMonths",
|
||||
"columnName": "retentionMonths",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hierarchyDelimiter",
|
||||
"columnName": "hierarchyDelimiter",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '02427d9d822cbb18a9bcd0670c048585')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 16,
|
||||
"identityHash": "b5c1a38d197cf1335d3092e413d55d0d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hierarchyDelimiter",
|
||||
"columnName": "hierarchyDelimiter",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b5c1a38d197cf1335d3092e413d55d0d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 17,
|
||||
"identityHash": "6fbe947ef0c6133ba5e621251a00fa1b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentId",
|
||||
"columnName": "contentId",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hierarchyDelimiter",
|
||||
"columnName": "hierarchyDelimiter",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fbe947ef0c6133ba5e621251a00fa1b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 18,
|
||||
"identityHash": "f4b80b1d988222a691f7aeea4d45e37e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentId",
|
||||
"columnName": "contentId",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hierarchyDelimiter",
|
||||
"columnName": "hierarchyDelimiter",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f4b80b1d988222a691f7aeea4d45e37e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "f00f0bf1851fb32d76dacee8558ae87a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f00f0bf1851fb32d76dacee8558ae87a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "e753f578e604e04fa7a697c18e80cb0b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e753f578e604e04fa7a697c18e80cb0b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Locks in the #157 regression fix: [IntentHandledMarker] must tell a redelivered (already-parsed)
|
||||
* intent apart from a genuinely new one using only the [Intent] instance itself, never
|
||||
* `savedInstanceState` — which Android also sets to non-null after a process-death relaunch, where the
|
||||
* delivered intent is a brand new, not-yet-handled notification tap or mailto/share, not a replay.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IntentHandledMarkerTest {
|
||||
|
||||
@Test
|
||||
fun first_look_marks_the_intent_and_reports_it_as_unhandled() {
|
||||
val intent = Intent(Intent.ACTION_MAIN)
|
||||
assertTrue(IntentHandledMarker.markIfUnhandled(intent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redelivering_the_same_instance_is_recognized_as_already_handled() {
|
||||
// Simulates a config-change recreation: Android redelivers the very same Intent object to the
|
||||
// new Activity instance's onCreate, so a second look must be recognized as a replay.
|
||||
val intent = Intent(Intent.ACTION_MAIN)
|
||||
IntentHandledMarker.markIfUnhandled(intent)
|
||||
assertFalse(IntentHandledMarker.markIfUnhandled(intent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_freshly_constructed_equivalent_intent_is_still_unhandled() {
|
||||
// Simulates a process-death relaunch (e.g. tapping a notification after the app was killed in
|
||||
// the background): a brand new Intent instance arrives — content may equal one already marked
|
||||
// in a prior (now-dead) process, but it was never marked in THIS one, so it must be parsed.
|
||||
val first = Intent(Intent.ACTION_VIEW)
|
||||
IntentHandledMarker.markIfUnhandled(first)
|
||||
|
||||
val second = Intent(Intent.ACTION_VIEW)
|
||||
assertTrue(IntentHandledMarker.markIfUnhandled(second))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
|
||||
/**
|
||||
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
|
||||
* per-account settings / signatures out of the cache database into the plaintext [AccountDatabase].
|
||||
*
|
||||
* Exercises the [AccountDataMigrator.copyAccountTables] core directly (the full [AccountDataMigrator]
|
||||
* also resolves the passphrase and flips the done-flag, which need the real DataStore/Keystore). A v14
|
||||
* cache is built with [MigrationTestHelper] from the exported schema, so the copy runs against exactly
|
||||
* the on-disk shape an upgrading user has.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDataMigratorTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
LibreMailDatabase::class.java,
|
||||
emptyList(),
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val cacheName = "acct-migrator-cache-test.db"
|
||||
private val accountsName = "acct-migrator-accounts-test.db"
|
||||
private val cacheFile get() = context.getDatabasePath(cacheName)
|
||||
private val accountsFile get() = context.getDatabasePath(accountsName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
@Before
|
||||
@After
|
||||
fun clean() {
|
||||
listOf(cacheName, accountsName).forEach { name ->
|
||||
context.deleteDatabase(name)
|
||||
context.getDatabasePath(name).parentFile
|
||||
?.listFiles { f -> f.name.startsWith(name) }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a v14 cache holding one fully-populated account plus a mail row. */
|
||||
private fun seedVersion14Cache() {
|
||||
helper.createDatabase(cacheName, 14).apply {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
|
||||
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
|
||||
execSQL(
|
||||
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled, " +
|
||||
"retentionCount, retentionMonths) VALUES ('acct', 'Cheers', 1, 0, NULL, 6)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO signatures (id, accountId, name, contentHtml, isDefault) " +
|
||||
"VALUES ('sig-1', 'acct', 'Work', '<p>Regards</p>', 1)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'a@x', 'Hi', '', '', 0, 1, 0, 0, 'INBOX', 1, 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAccountsDb(): AccountDatabase =
|
||||
Room.databaseBuilder(context, AccountDatabase::class.java, accountsName).build()
|
||||
|
||||
@Test
|
||||
fun movesEveryAccountTableOutOfAPlaintextCache() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// First open lets Room stamp its identity onto the migrator-created file; reopen so a real
|
||||
// session's reads run against a fully Room-owned database.
|
||||
openAccountsDb().apply {
|
||||
assertEquals("Ada", accountDao().getById("acct")?.displayName)
|
||||
close()
|
||||
}
|
||||
openAccountsDb().apply {
|
||||
val account = accountDao().getById("acct")
|
||||
assertEquals("ada@example.org", account?.email)
|
||||
assertEquals(993, account?.imap?.port)
|
||||
assertEquals("smtp.example.org", account?.smtp?.host)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
val settings = accountSettingsDao().get("acct")
|
||||
// Seeded signatureEnabled = 1, notificationsEnabled = 0: both booleans must round-trip.
|
||||
assertEquals(true, settings?.signatureEnabled)
|
||||
assertEquals(false, settings?.notificationsEnabled)
|
||||
assertEquals(6, settings?.retentionMonths)
|
||||
assertNull(settings?.retentionCount)
|
||||
val signatures = signatureDao().observeForAccount("acct").first()
|
||||
assertEquals(listOf("Work"), signatures.map { it.name })
|
||||
assertTrue("the default flag must round-trip", signatures.single().isDefault)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movesAccountsOutOfAnEncryptedCache() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
// Turn the cache into the SQLCipher form an app-lock + encrypted-cache user has on disk.
|
||||
DatabaseEncryption.ensureEncrypted(cacheFile, passphrase)
|
||||
assertTrue("precondition: the source cache is encrypted", DatabaseEncryption.isEncrypted(cacheFile))
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = passphrase, accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertNotNull(accountDao().getById("acct"))
|
||||
close()
|
||||
}
|
||||
openAccountsDb().apply {
|
||||
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// Simulate the user editing an account AFTER the migration.
|
||||
openAccountsDb().apply {
|
||||
val edited = accountDao().getById("acct")!!.copy(displayName = "Ada Lovelace")
|
||||
accountDao().upsert(edited)
|
||||
close()
|
||||
}
|
||||
|
||||
// A re-run (e.g. after a mid-startup crash before the done-flag was set) must not clobber it.
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertEquals(1, accountDao().getAll().size)
|
||||
assertEquals(
|
||||
"INSERT OR IGNORE must not overwrite the post-migration edit",
|
||||
"Ada Lovelace",
|
||||
accountDao().getById("acct")?.displayName,
|
||||
)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsAndCredentialsSurviveACacheWipe() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// The "clear + re-sync" recovery wipes only the cache file; AccountDatabase is a separate file.
|
||||
context.deleteDatabase(cacheName)
|
||||
assertTrue("precondition: the cache file is gone", !cacheFile.exists())
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertNotNull("the account must outlive a cache wipe (issue #111)", accountDao().getById("acct"))
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
// And it is still usable: a fresh credential can be written with no cache present.
|
||||
credentialDao().upsert(CredentialEntity("acct", "rotated"))
|
||||
assertEquals("rotated", credentialDao().getById("acct")?.encryptedSecret)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesFromACacheOlderThanTheCurrentSchema() = runBlocking<Unit> {
|
||||
// A cache last written at v12 — before account_settings gained retentionCount/retentionMonths
|
||||
// (v13). The copy must not choke on the columns the destination has but the source lacks
|
||||
// (a device upgrade from an old install crashed the migrator here).
|
||||
helper.createDatabase(cacheName, 12).apply {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
|
||||
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
|
||||
execSQL(
|
||||
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " +
|
||||
"VALUES ('acct', 'Sig', 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
val settings = accountSettingsDao().get("acct")
|
||||
assertEquals(false, settings?.signatureEnabled)
|
||||
assertEquals(true, settings?.notificationsEnabled)
|
||||
// Columns the v12 source lacked come across as the destination's defaults (null).
|
||||
assertNull("retentionCount absent from a v12 cache must default to null", settings?.retentionCount)
|
||||
assertNull(settings?.retentionMonths)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
|
||||
val schema = JSONObject(
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("org.libremail.data.local.AccountDatabase/1.json")
|
||||
.bufferedReader().use { it.readText() },
|
||||
).getJSONObject("database")
|
||||
val entities = schema.getJSONArray("entities")
|
||||
|
||||
var checkedIndex = false
|
||||
for (i in 0 until entities.length()) {
|
||||
val entity = entities.getJSONObject(i)
|
||||
val table = entity.getString("tableName")
|
||||
val expectedCreate = entity.getString("createSql").replace("\${TABLE_NAME}", table)
|
||||
assertEquals(
|
||||
"AccountDataMigrator DDL for `$table` must match the exported AccountDatabase schema",
|
||||
expectedCreate,
|
||||
AccountDataMigrator.CREATE_TABLE_SQL[table],
|
||||
)
|
||||
if (entity.has("indices")) {
|
||||
val indices = entity.getJSONArray("indices")
|
||||
for (j in 0 until indices.length()) {
|
||||
val index = indices.getJSONObject(j)
|
||||
if (index.getString("name") == "index_signatures_accountId") {
|
||||
assertEquals(
|
||||
"AccountDataMigrator signatures index must match the exported schema",
|
||||
index.getString("createSql").replace("\${TABLE_NAME}", table),
|
||||
AccountDataMigrator.SIGNATURES_INDEX_SQL,
|
||||
)
|
||||
checkedIndex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
"every migrator table DDL must correspond to an exported entity",
|
||||
AccountDataMigrator.CREATE_TABLE_SQL.keys,
|
||||
(0 until entities.length()).map { entities.getJSONObject(it).getString("tableName") }.toSet(),
|
||||
)
|
||||
assertTrue("the signatures index must be present in the exported schema", checkedIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
/**
|
||||
* Behavior of the non-auth [AccountDatabase] that holds accounts, credentials, per-account settings
|
||||
* and signatures after they were moved out of the cache database (issue #111). Confirms the tables'
|
||||
* foreign keys still cascade from the account, now that they live together in this database.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDatabaseTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun account(id: String = "acct") = AccountEntity(
|
||||
id = id,
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun credentialRoundTripsAndIsIndependentOfTheAccountRow() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
db.credentialDao().upsert(CredentialEntity("acct", "sealed-secret"))
|
||||
|
||||
assertEquals("sealed-secret", db.credentialDao().getById("acct")?.encryptedSecret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
db.accountSettingsDao().upsert(
|
||||
AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false),
|
||||
)
|
||||
assertEquals("Hi", db.accountSettingsDao().get("acct")?.signature)
|
||||
|
||||
db.accountDao().deleteById("acct")
|
||||
|
||||
assertNull("account_settings must cascade-delete with its account", db.accountSettingsDao().get("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signaturesCascadeWithTheirAccount() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
db.signatureDao().upsert(SignatureEntity("sig-1", "acct", "Work", "<p>Regards</p>", isDefault = true))
|
||||
assertEquals(1, db.signatureDao().observeForAccount("acct").first().size)
|
||||
|
||||
db.accountDao().deleteById("acct")
|
||||
|
||||
assertTrue(
|
||||
"signatures must cascade-delete with their account",
|
||||
db.signatureDao().observeForAccount("acct").first().isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
@@ -17,6 +16,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Round-trips the cache database through [DatabaseEncryption] (plaintext → encrypted → plaintext),
|
||||
@@ -53,7 +53,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openEncrypted().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
|
||||
/**
|
||||
* Schema-behavior tests on the real (v7) Room database. (Migrations from versions before
|
||||
* exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs
|
||||
* were never exported; exportSchema is now on so future migrations can be tested.)
|
||||
* Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL
|
||||
* itself is exercised by [MigrationTest], which replays the schema chain exported to app/schemas.
|
||||
* (Migrations from before v7 predate schema export, so they can't be replayed there.)
|
||||
*
|
||||
* Account/credential/settings/signature behavior moved to [AccountDatabaseTest] with those tables
|
||||
* (issue #111).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibreMailDatabaseTest {
|
||||
@@ -48,6 +52,34 @@ class LibreMailDatabaseTest {
|
||||
isStarred = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun observeUnreadCountsAggregatesUnreadSyncedRowsPerAccountAndFolder() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
messageDao.insertNew(
|
||||
listOf(
|
||||
// acct / INBOX: two unread + one read -> counts 2.
|
||||
message("acct:INBOX:1").copy(folder = "INBOX", isRead = false),
|
||||
message("acct:INBOX:2").copy(folder = "INBOX", isRead = false),
|
||||
message("acct:INBOX:3").copy(folder = "INBOX", isRead = true),
|
||||
// acct / Archive: one unread -> counts 1.
|
||||
message("acct:Archive:1").copy(folder = "Archive", isRead = false),
|
||||
// An unread server-search hit (inInbox = false) must never inflate a badge.
|
||||
message("acct:INBOX:search").copy(folder = "INBOX", isRead = false, inInbox = false),
|
||||
// A second account's unread inbox row is counted under its own accountId.
|
||||
message("acct2:INBOX:1").copy(accountId = "acct2", folder = "INBOX", isRead = false),
|
||||
),
|
||||
)
|
||||
|
||||
val counts = messageDao.observeUnreadCounts().first()
|
||||
.associate { (it.accountId to it.folder) to it.unreadCount }
|
||||
|
||||
assertEquals(2, counts[("acct" to "INBOX")])
|
||||
assertEquals(1, counts[("acct" to "Archive")])
|
||||
assertEquals(1, counts[("acct2" to "INBOX")])
|
||||
// Fully-read folders and search-only rows produce no group at all.
|
||||
assertEquals(3, counts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingMessageCascadesToItsAttachments() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
@@ -64,16 +96,107 @@ class LibreMailDatabaseTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeForMessageHidesInlineImagesWhileGetForMessageKeepsThem() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
val attachmentDao = db.attachmentDao()
|
||||
messageDao.insertNew(listOf(message("acct:1")))
|
||||
attachmentDao.insert(
|
||||
listOf(
|
||||
AttachmentEntity("acct:1", 0, "logo.png", "image/png", 4, contentId = "logo1"),
|
||||
AttachmentEntity("acct:1", 1, "invoice.pdf", "application/pdf", 10, contentId = null),
|
||||
),
|
||||
)
|
||||
|
||||
// The displayed list excludes inline cid: images (issue #133) ...
|
||||
val displayed = attachmentDao.observeForMessage("acct:1").first()
|
||||
assertEquals(listOf("invoice.pdf"), displayed.map { it.filename })
|
||||
// ... while the full read keeps them so their bytes can back a cid: request.
|
||||
assertEquals(2, attachmentDao.getForMessage("acct:1").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchRowsAreNotInboxAndAreCleared() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
messageDao.insertNew(listOf(message("acct:1").copy(inInbox = true)))
|
||||
messageDao.insertNew(listOf(message("acct:2").copy(inInbox = false)))
|
||||
|
||||
assertEquals(listOf("acct:1"), messageDao.getInboxIdsForAccount("acct"))
|
||||
assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX"))
|
||||
|
||||
messageDao.deleteSearchRows()
|
||||
val remaining = messageDao.observeAll().first().map { it.id }
|
||||
val remaining = messageDao.observeSummaries().first().map { it.id }
|
||||
assertEquals(listOf("acct:1"), remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeSummariesReadsRowsWhoseBodiesExceedTheCursorWindow() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
// Each body is larger than SQLite's shared (~2 MB) CursorWindow. The old list query did
|
||||
// SELECT * and dragged these bodies through the window, overflowing it with
|
||||
// "Couldn't read row … from CursorWindow" (issue #51). observeSummaries omits body, so the
|
||||
// rows stay tiny and read fine.
|
||||
val hugeBody = "x".repeat(3 * 1024 * 1024)
|
||||
messageDao.insertNew(
|
||||
listOf(
|
||||
message("acct:1", body = hugeBody),
|
||||
message("acct:2", body = hugeBody),
|
||||
),
|
||||
)
|
||||
|
||||
val ids = messageDao.observeSummaries().first().map { it.id }.toSet()
|
||||
|
||||
assertEquals(setOf("acct:1", "acct:2"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldersAreStoredOrderedAndReplaceablePerAccount() = runBlocking {
|
||||
val folderDao = db.folderDao()
|
||||
folderDao.replaceForAccount(
|
||||
"acct",
|
||||
listOf(
|
||||
FolderEntity(
|
||||
accountId = "acct",
|
||||
fullName = "[Gmail]/Sent Mail",
|
||||
displayName = "Sent Mail",
|
||||
role = "SENT",
|
||||
selectable = true,
|
||||
sortOrder = 1,
|
||||
specialUse = true,
|
||||
),
|
||||
FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0),
|
||||
),
|
||||
)
|
||||
// observeForAccount returns folders ordered by sortOrder, with specialUse round-tripped.
|
||||
val stored = folderDao.observeForAccount("acct").first()
|
||||
assertEquals(listOf("INBOX", "[Gmail]/Sent Mail"), stored.map { it.fullName })
|
||||
assertEquals(listOf(false, true), stored.map { it.specialUse })
|
||||
|
||||
// replaceForAccount swaps the whole set (delete + insert).
|
||||
folderDao.replaceForAccount("acct", listOf(FolderEntity("acct", "Archive", "Archive", "ARCHIVE", true, 0)))
|
||||
assertEquals(listOf("Archive"), folderDao.observeForAccount("acct").first().map { it.fullName })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncReconcileIsScopedToASingleFolder() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
messageDao.insertNew(
|
||||
listOf(
|
||||
message("acct:INBOX:1").copy(folder = "INBOX"),
|
||||
message("acct:INBOX:2").copy(folder = "INBOX"),
|
||||
message("acct:Archive:1").copy(folder = "Archive"),
|
||||
),
|
||||
)
|
||||
|
||||
// getSyncedIds is scoped to one folder.
|
||||
assertEquals(setOf("acct:INBOX:1", "acct:INBOX:2"), messageDao.getSyncedIds("acct", "INBOX").toSet())
|
||||
assertEquals(listOf("acct:Archive:1"), messageDao.getSyncedIds("acct", "Archive"))
|
||||
|
||||
// Reconciling the inbox must not touch other folders' rows (windowed reconcile; whole-inbox
|
||||
// window since these rows have uid 0).
|
||||
messageDao.deleteSyncedInWindowNotIn("acct", "INBOX", minWindowUid = 0, keepIds = listOf("acct:INBOX:1"))
|
||||
assertEquals(
|
||||
setOf("acct:INBOX:1", "acct:Archive:1"),
|
||||
messageDao.observeSummaries().first().map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of the retention / backfill boundary queries on [MessageDao] (issues #12/#13).
|
||||
*
|
||||
* These queries *define* the device-only retention floor — the pruner deletes below it
|
||||
* ([MessageDao.syncedIdsBeyondCountInFolder] / [MessageDao.syncedIdsOlderThan]) and the backfiller
|
||||
* pages and stops around it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced]) — so the whole
|
||||
* "backfill and prune never fight over the same rows" guarantee rests on their SQL. The
|
||||
* [org.libremail.data.sync.MailPruner] / [org.libremail.data.sync.MailBackfiller] unit tests mock
|
||||
* the DAO, so the `ORDER BY … DESC LIMIT` newest-N selection, the strict age cutoff, and the
|
||||
* windowed reconcile that spares backfilled history are exercised here against a real database
|
||||
* instead.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageDaoRetentionTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: MessageDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.messageDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
accountId: String = "acct",
|
||||
folder: String = "INBOX",
|
||||
uid: Long = 0L,
|
||||
timestampMillis: Long = 1_000L,
|
||||
inInbox: Boolean = true,
|
||||
) = MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = "",
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
uid = uid,
|
||||
)
|
||||
|
||||
/**
|
||||
* The count-based prune boundary keeps the newest [keep] by ARRIVAL (server UID) and returns the
|
||||
* REST for deletion. Keeping by UID — not by the Date header — matches the newest-by-UID recent
|
||||
* window foreground sync re-fetches, so a high-UID/old-Date message isn't re-downloaded every sync
|
||||
* and re-pruned every cycle. This pins the ordering column (a Date-ordered keep would evict the
|
||||
* high-UID/old-Date row) and its direction (a flipped DESC would keep the OLDEST arrivals).
|
||||
*/
|
||||
@Test
|
||||
fun syncedIdsBeyondCountInFolderKeepsNewestByArrivalUid() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("recent-old-date", uid = 100, timestampMillis = 50), // newest arrival, oldest Date
|
||||
message("A", uid = 30, timestampMillis = 300),
|
||||
message("B", uid = 25, timestampMillis = 200),
|
||||
message("D", uid = 10, timestampMillis = 100),
|
||||
// Scoping decoys: a search-only row and another folder must never enter the ranking.
|
||||
message("SR", uid = 99, timestampMillis = 999, inInbox = false),
|
||||
message("AR", uid = 5, timestampMillis = 50, folder = "Archive"),
|
||||
),
|
||||
)
|
||||
|
||||
// Keep the newest 2 by UID (recent-old-date, A); the rest are prunable. A Date-ordered keep would
|
||||
// wrongly evict recent-old-date (oldest Date) and keep B.
|
||||
assertEquals(
|
||||
setOf("B", "D"),
|
||||
dao.syncedIdsBeyondCountInFolder("acct", "INBOX", keep = 2).toSet(),
|
||||
)
|
||||
// Keeping at least as many as exist prunes nothing.
|
||||
assertEquals(emptyList<String>(), dao.syncedIdsBeyondCountInFolder("acct", "INBOX", keep = 4))
|
||||
}
|
||||
|
||||
/**
|
||||
* The age-based prune boundary selects rows STRICTLY older than the cutoff, across every folder,
|
||||
* scoped to the account and to synced (non-search) rows only.
|
||||
*/
|
||||
@Test
|
||||
fun syncedIdsOlderThanCutsStrictlyBelowAcrossFoldersAndScopesToAccount() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("boundary", uid = 25, timestampMillis = 200), // == cutoff -> kept (strict `<`)
|
||||
message("old-inbox", uid = 10, timestampMillis = 100),
|
||||
message("old-archive", uid = 5, timestampMillis = 150, folder = "Archive"),
|
||||
message("old-search", uid = 1, timestampMillis = 1, inInbox = false), // not synced
|
||||
message("old-other-account", accountId = "acct2", uid = 1, timestampMillis = 1),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf("old-inbox", "old-archive"),
|
||||
dao.syncedIdsOlderThan("acct", cutoffMillis = 200).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The windowed reconcile deletes only synced rows at/above the recent-UID window that the server no
|
||||
* longer returns; older backfilled history (below the window), other folders, and search rows are
|
||||
* left intact — the core guarantee that a foreground sync no longer wipes backfilled history (#12).
|
||||
*/
|
||||
@Test
|
||||
fun deleteSyncedInWindowNotInSparesBelowWindowHistoryAndOtherFolders() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("below", uid = 10), // below the window -> spared
|
||||
message("kept", uid = 20), // in window, in keep set -> spared
|
||||
message("gone-1", uid = 30), // in window, not kept -> deleted
|
||||
message("gone-2", uid = 40), // in window, not kept -> deleted
|
||||
message("search", uid = 22, inInbox = false), // not synced -> spared
|
||||
message("other-folder", uid = 25, folder = "Archive"), // different folder -> spared
|
||||
),
|
||||
)
|
||||
|
||||
dao.deleteSyncedInWindowNotIn("acct", "INBOX", minWindowUid = 20, keepIds = listOf("kept"))
|
||||
|
||||
// Read survivors back with point lookups (getById) rather than observeAll(): explicit about each
|
||||
// row's fate, and it keeps the assertion off the Flow API.
|
||||
assertNull("gone-1 is in-window and unkept -> deleted", dao.getById("gone-1"))
|
||||
assertNull("gone-2 is in-window and unkept -> deleted", dao.getById("gone-2"))
|
||||
assertNotNull("below-window history must survive", dao.getById("below"))
|
||||
assertNotNull("the kept row must survive", dao.getById("kept"))
|
||||
assertNotNull("search rows are not synced -> untouched", dao.getById("search"))
|
||||
assertNotNull("other folders are untouched", dao.getById("other-folder"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The backfiller's floor probes reflect only an account's synced rows in the given folder, and are
|
||||
* null/zero for a folder with nothing cached (so the backfiller then starts from `Long.MAX_VALUE`).
|
||||
* The paging boundary additionally skips `uid <= 0` placeholder rows (#95): a row migrated before
|
||||
* the `uid` column existed (backfilled to 0) or one whose UID the server failed to resolve (-1)
|
||||
* must not collapse MIN(uid) to a bound the backfiller treats as "folder fully paged".
|
||||
*/
|
||||
@Test
|
||||
fun floorProbesReflectOnlySyncedRowsInTheFolder() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("a", uid = 30, timestampMillis = 300),
|
||||
message("d", uid = 10, timestampMillis = 100),
|
||||
message("legacy", uid = 0, timestampMillis = 40), // pre-uid-column migration row (#95)
|
||||
message("unresolved", uid = -1, timestampMillis = 30), // UIDFolder.getUID failure (#95)
|
||||
message("search", uid = 1, timestampMillis = 1, inInbox = false), // excluded
|
||||
message("archive", uid = 5, timestampMillis = 50, folder = "Archive"), // different folder
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(10L, dao.lowestSyncedUid("acct", "INBOX"))
|
||||
assertEquals(4, dao.countSynced("acct", "INBOX"))
|
||||
|
||||
assertNull(dao.lowestSyncedUid("acct", "Nonexistent"))
|
||||
assertEquals(0, dao.countSynced("acct", "Nonexistent"))
|
||||
|
||||
// A folder holding ONLY placeholder rows has no usable boundary: null (start from the top).
|
||||
dao.insertNew(listOf(message("only-legacy", uid = 0, folder = "Imported")))
|
||||
assertNull(dao.lowestSyncedUid("acct", "Imported"))
|
||||
}
|
||||
|
||||
/** Backfill / prune enumerate their targets via [syncedFolders]: distinct synced folders, per account. */
|
||||
@Test
|
||||
fun syncedFoldersReturnsDistinctSyncedFoldersForTheAccount() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("i1", folder = "INBOX"),
|
||||
message("i2", folder = "INBOX"),
|
||||
message("ar", folder = "Archive"),
|
||||
message("sr", folder = "Search", inInbox = false), // search-only folder excluded
|
||||
message("other", accountId = "acct2", folder = "Spam"), // other account excluded
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(setOf("INBOX", "Archive"), dao.syncedFolders("acct").toSet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of the body-less routing projection [MessageDao.getRouting] /
|
||||
* [MessageDao.getRoutingByIds] (issue #186). The open path and the flag/move callers route on these
|
||||
* instead of pulling the whole `body` blob through [MessageDao.getById]. These tests pin that the
|
||||
* projection maps every routing/flag column correctly (and can do so for a row whose body is large,
|
||||
* which is exactly the over-fetch the projection avoids).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageDaoRoutingTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: MessageDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.messageDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun message(
|
||||
id: String,
|
||||
accountId: String = "acct",
|
||||
folder: String = "INBOX",
|
||||
uid: Long = 0L,
|
||||
isRead: Boolean = false,
|
||||
isStarred: Boolean = false,
|
||||
bodyFetched: Boolean = false,
|
||||
isHtml: Boolean = false,
|
||||
body: String = "",
|
||||
) = MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = body,
|
||||
isHtml = isHtml,
|
||||
timestampMillis = 1_000L,
|
||||
isRead = isRead,
|
||||
isStarred = isStarred,
|
||||
folder = folder,
|
||||
bodyFetched = bodyFetched,
|
||||
uid = uid,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun getRoutingProjectsEveryRoutingAndFlagColumn() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message(
|
||||
id = "acct:Archive:9",
|
||||
folder = "Archive",
|
||||
uid = 9,
|
||||
isRead = true,
|
||||
isStarred = true,
|
||||
bodyFetched = true,
|
||||
isHtml = true,
|
||||
body = "x".repeat(4_000), // a large body the projection must not need to read
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val routing = requireNotNull(dao.getRouting("acct:Archive:9"))
|
||||
|
||||
assertEquals("acct:Archive:9", routing.id)
|
||||
assertEquals("acct", routing.accountId)
|
||||
assertEquals("Archive", routing.folder)
|
||||
assertEquals(9L, routing.uid)
|
||||
assertEquals(true, routing.isRead)
|
||||
assertEquals(true, routing.isStarred)
|
||||
assertEquals(true, routing.bodyFetched)
|
||||
assertEquals(true, routing.isHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRoutingIsNullForAnUnknownId() = runBlocking {
|
||||
assertNull(dao.getRouting("acct:INBOX:404"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRoutingByIdsReturnsOnlyTheRequestedRows() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message(id = "acct:INBOX:1", folder = "INBOX", uid = 1),
|
||||
message(id = "acct:INBOX:2", folder = "INBOX", uid = 2),
|
||||
message(id = "acct2:Sent:3", folder = "Sent", accountId = "acct2", uid = 3),
|
||||
),
|
||||
)
|
||||
|
||||
val routings = dao.getRoutingByIds(listOf("acct:INBOX:1", "acct2:Sent:3"))
|
||||
|
||||
assertEquals(setOf("acct:INBOX:1", "acct2:Sent:3"), routings.map { it.id }.toSet())
|
||||
assertEquals(setOf("INBOX", "Sent"), routings.map { it.folder }.toSet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Validates the v12 -> v13 migration (issues #12/#13): `messages.uid` is added and backfilled from the
|
||||
* id's numeric tail, `account_settings` gains the nullable retention overrides, and the
|
||||
* `backfill_progress` table is created. `runMigrationsAndValidate` additionally checks the whole
|
||||
* migrated schema matches the exported v13 schema.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class Migration12To13Test {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
LibreMailDatabase::class.java,
|
||||
emptyList(),
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrate12To13_backfillsUidAndAddsRetentionAndBackfillTables() {
|
||||
helper.createDatabase(DB_NAME, 12).apply {
|
||||
// A plain inbox row, a folder name containing special characters, and a folder name that
|
||||
// ends in a digit — all must recover the trailing UID correctly.
|
||||
insertV12Message("acct:INBOX:42")
|
||||
insertV12Message("acct:[Gmail]/Sent Mail:7")
|
||||
insertV12Message("acct:Folder2:15")
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(DB_NAME, 13, true, MIGRATION_12_13)
|
||||
|
||||
// uid is materialized from the id's numeric tail.
|
||||
assertEquals(42L, uidOf(db, "acct:INBOX:42"))
|
||||
assertEquals(7L, uidOf(db, "acct:[Gmail]/Sent Mail:7"))
|
||||
assertEquals(15L, uidOf(db, "acct:Folder2:15"))
|
||||
|
||||
// The new retention columns exist and default to NULL (= inherit the global default).
|
||||
db.query("SELECT retentionCount, retentionMonths FROM account_settings").use { c ->
|
||||
// No rows required; the query succeeding proves the columns exist.
|
||||
assertTrue(c.columnCount == 2)
|
||||
}
|
||||
|
||||
// The backfill_progress table exists and accepts a row.
|
||||
db.execSQL(
|
||||
"INSERT INTO backfill_progress (accountId, folder, nextBeforeUid, complete) " +
|
||||
"VALUES ('acct', 'INBOX', 41, 0)",
|
||||
)
|
||||
db.query("SELECT nextBeforeUid FROM backfill_progress WHERE accountId='acct' AND folder='INBOX'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals(41L, c.getLong(0))
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun uidOf(db: androidx.sqlite.db.SupportSQLiteDatabase, id: String): Long =
|
||||
db.query("SELECT uid FROM messages WHERE id = ?", arrayOf<Any>(id)).use { c ->
|
||||
assertTrue("row $id must exist", c.moveToFirst())
|
||||
c.getLong(0)
|
||||
}
|
||||
|
||||
private fun androidx.sqlite.db.SupportSQLiteDatabase.insertV12Message(id: String) {
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched) " +
|
||||
"VALUES (?, 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, 'INBOX', 1, 0)",
|
||||
arrayOf<Any>(id),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DB_NAME = "migration-12-13-test.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
* Replays the app's Room migrations against the schema JSONs exported to `app/schemas` (shipped to
|
||||
* the test APK as assets). DatabaseModule deliberately registers no destructive fallback — dropping
|
||||
* tables would lose stored accounts and credentials — so a drifted migration crashes upgrading
|
||||
* users at first database open; these tests make such drift fail in CI instead (issue #63).
|
||||
*
|
||||
* The migration list is discovered from Migrations.kt and the target version from the newest
|
||||
* exported schema, so a future migration is covered automatically: author the `Migration`, register
|
||||
* it in DatabaseModule, commit the new schema JSON, and [migratingFromV7ReplaysEveryMigrationAndPreservesData]
|
||||
* replays it with no edit here. (Only v7+ can be replayed — older versions predate schema export.)
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MigrationTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
LibreMailDatabase::class.java,
|
||||
emptyList(),
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
/** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */
|
||||
@Test
|
||||
fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() {
|
||||
helper.createDatabase(TEST_DB, 11).apply {
|
||||
insertAccount()
|
||||
execSQL(
|
||||
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) VALUES " +
|
||||
"('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0), " +
|
||||
"('acct', '[Gmail]/Sent Mail', 'Sent Mail', 'SENT', 1, 1)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched) " +
|
||||
"VALUES ('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
|
||||
"'INBOX', 1, 0)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 12, true, MIGRATION_11_12)
|
||||
|
||||
db.query("SELECT fullName, specialUse FROM folders ORDER BY sortOrder").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("INBOX", c.getString(0))
|
||||
assertEquals(0, c.getInt(1))
|
||||
assertTrue(c.moveToNext())
|
||||
assertEquals("[Gmail]/Sent Mail", c.getString(0))
|
||||
assertEquals(0, c.getInt(1))
|
||||
assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext())
|
||||
}
|
||||
// The folders' account and cached mail are untouched.
|
||||
assertEquals(1, db.count("accounts"))
|
||||
assertEquals(1, db.count("messages"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v14 -> v15 (issue #66): `folders.hierarchyDelimiter` appears defaulting to NULL; data survives. */
|
||||
@Test
|
||||
fun migrate14To15_addsNullHierarchyDelimiterToFolders() {
|
||||
helper.createDatabase(TEST_DB, 14).apply {
|
||||
insertAccount()
|
||||
execSQL(
|
||||
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder, specialUse) " +
|
||||
"VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0, 0), " +
|
||||
"('acct', 'Work.Reports', 'Reports', 'NORMAL', 1, 1, 0)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 15, true, MIGRATION_14_15)
|
||||
|
||||
db.query("SELECT fullName, hierarchyDelimiter FROM folders ORDER BY sortOrder").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("INBOX", c.getString(0))
|
||||
assertTrue("existing folders read a null delimiter until the next refresh", c.isNull(1))
|
||||
assertTrue(c.moveToNext())
|
||||
assertEquals("Work.Reports", c.getString(0))
|
||||
assertTrue(c.isNull(1))
|
||||
assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext())
|
||||
}
|
||||
// The folders' account is untouched.
|
||||
assertEquals(1, db.count("accounts"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* A gap in the chain (or a migration whose target schema was never committed) crashes upgrading
|
||||
* users, so fail fast with a readable message before replaying anything.
|
||||
*/
|
||||
@Test
|
||||
fun migrationsFormOneUnbrokenChainUpToTheLatestExportedSchema() {
|
||||
assertEquals(
|
||||
"Migrations.kt must chain every version step up to the newest exported schema " +
|
||||
"(DatabaseModule registers no destructive fallback, so a gap crashes upgrades)",
|
||||
(1 until latestExportedSchemaVersion()).map { it to it + 1 },
|
||||
allAppMigrations.map { it.startVersion to it.endVersion },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a database at v7 (the oldest exported schema), fills it like a used install, then
|
||||
* replays every migration one step at a time — `runMigrationsAndValidate` diffs the migrated
|
||||
* schema against each version's exported JSON, so a failure names the exact step that drifted.
|
||||
*/
|
||||
@Test
|
||||
fun migratingFromV7ReplaysEveryMigrationAndPreservesData() {
|
||||
helper.createDatabase(TEST_DB, OLDEST_EXPORTED_SCHEMA).apply {
|
||||
seedVersion7Cache()
|
||||
close()
|
||||
}
|
||||
|
||||
var open: SupportSQLiteDatabase? = null
|
||||
for (migration in allAppMigrations.filter { it.startVersion >= OLDEST_EXPORTED_SCHEMA }) {
|
||||
open?.close()
|
||||
val stepDb = helper.runMigrationsAndValidate(TEST_DB, migration.endVersion, true, migration)
|
||||
stepDb.writeMidChainData()
|
||||
// v16 moves the account tables out to AccountDatabase and drops them, so assert their rows
|
||||
// and backfills reached v15 intact — just before the move (issue #111).
|
||||
if (stepDb.version == 15) stepDb.assertAccountDataPresentAtV15()
|
||||
open = stepDb
|
||||
}
|
||||
val db = checkNotNull(open) { "no migration starts at v$OLDEST_EXPORTED_SCHEMA" }
|
||||
|
||||
assertEquals("the chain must end at the newest exported schema", latestExportedSchemaVersion(), db.version)
|
||||
db.assertVersion7CacheSurvived()
|
||||
db.assertMigrationBackfillsApplied()
|
||||
db.assertAccountTablesDroppedAtV16()
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v15 -> v16 (issue #111): the moved account tables are dropped and the mail cache is untouched. */
|
||||
@Test
|
||||
fun migrate15To16_dropsMovedAccountTablesAndKeepsCache() {
|
||||
helper.createDatabase(TEST_DB, 15).apply {
|
||||
insertAccount()
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed')")
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
|
||||
"'INBOX', 1, 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, MIGRATION_15_16)
|
||||
|
||||
db.assertAccountTablesDroppedAtV16()
|
||||
assertEquals("the mail cache must be untouched by 15->16", 1, db.count("messages"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v16 -> v17 (issue #133): `attachments.contentId` appears defaulting to NULL; cached rows survive. */
|
||||
@Test
|
||||
fun migrate16To17_addsNullContentIdToAttachments() {
|
||||
helper.createDatabase(TEST_DB, 16).apply {
|
||||
// v16 dropped the account tables, so a message (no FK to accounts) plus its attachment is
|
||||
// all that's needed to exercise the attachments table rebuild.
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
|
||||
"'INBOX', 1, 1, 1)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " +
|
||||
"VALUES ('acct:INBOX:1', 0, 'report.pdf', 'application/pdf', 2048)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, MIGRATION_16_17)
|
||||
|
||||
db.query("SELECT filename, contentId FROM attachments WHERE messageId = 'acct:INBOX:1'").use { c ->
|
||||
assertTrue("the pre-upgrade attachment row must survive", c.moveToFirst())
|
||||
assertEquals("report.pdf", c.getString(0))
|
||||
assertTrue("existing attachments read a null contentId (treated as ordinary downloads)", c.isNull(1))
|
||||
assertFalse("only the one pre-upgrade attachment row must survive", c.moveToNext())
|
||||
}
|
||||
assertEquals("the cached message must be untouched by 16->17", 1, db.count("messages"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v17 -> v18 (issue #77): `outbox.attachments` appears defaulting to '' and the queued row survives. */
|
||||
@Test
|
||||
fun migrate17To18_addsEmptyAttachmentsToOutbox() {
|
||||
helper.createDatabase(TEST_DB, 17).apply {
|
||||
// v16 dropped the account tables, so a bare outbox row is all this migration needs.
|
||||
execSQL(
|
||||
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, bccAddresses, subject, body, " +
|
||||
"createdAt, lastError, bodyHtml) VALUES ('out-1', 'acct', 'bob@example.org', '', '', " +
|
||||
"'Queued', 'Body', 3000, NULL, NULL)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 18, true, MIGRATION_17_18)
|
||||
|
||||
db.query("SELECT subject, attachments FROM outbox WHERE id = 'out-1'").use { c ->
|
||||
assertTrue("the queued outbox row must survive", c.moveToFirst())
|
||||
assertEquals("Queued", c.getString(0))
|
||||
assertEquals("existing outbox rows read empty attachment metadata", "", c.getString(1))
|
||||
assertFalse("only the one pre-upgrade outbox row must survive", c.moveToNext())
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** The newest schema JSON exported to app/schemas (shipped to the test APK as assets). */
|
||||
private fun latestExportedSchemaVersion(): Int {
|
||||
val schemaFolder = checkNotNull(LibreMailDatabase::class.java.canonicalName)
|
||||
val versions = InstrumentationRegistry.getInstrumentation().context.assets.list(schemaFolder)
|
||||
.orEmpty()
|
||||
.mapNotNull { it.removeSuffix(".json").toIntOrNull() }
|
||||
check(versions.isNotEmpty()) { "no exported schemas under androidTest assets/$schemaFolder" }
|
||||
return versions.max()
|
||||
}
|
||||
|
||||
/** The accounts table has kept this shape since before v7, so every test can share one insert. */
|
||||
private fun SupportSQLiteDatabase.insertAccount() {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
|
||||
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
}
|
||||
|
||||
/** Fills a v7 database the way a used install would look (columns exactly as in 7.json). */
|
||||
private fun SupportSQLiteDatabase.seedVersion7Cache() {
|
||||
insertAccount()
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, inInbox, bodyFetched) VALUES " +
|
||||
"('acct:1', 'acct', 'Ada', 'ada@example.org', 'Analytical engines', 'Dear Charles', " +
|
||||
"'Dear Charles, the mill works.', 0, 1000, 1, 0, 1, 1), " +
|
||||
"('acct:2', 'acct', 'Charles', 'charles@example.org', 'Re: engines', '', '', 0, 2000, 0, 1, 1, 0)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " +
|
||||
"VALUES ('acct:1', 0, 'notes.pdf', 'application/pdf', 2048)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, subject, body, createdAt, " +
|
||||
"lastError) VALUES ('out-1', 'acct', 'charles@example.org', '', 'Queued', 'Body', 3000, NULL)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO drafts (id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, " +
|
||||
"attachments) VALUES ('draft-1', 'acct', 'charles@example.org', '', 'Draft', 'Text', 4000, '')",
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows a user would write at intermediate versions; they must survive the rest of the chain. */
|
||||
private fun SupportSQLiteDatabase.writeMidChainData() {
|
||||
when (version) {
|
||||
// v8 is the first version with a folders table; 11->12 must stamp this row specialUse = 0.
|
||||
8 -> execSQL(
|
||||
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) " +
|
||||
"VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0)",
|
||||
)
|
||||
// A signature saved by a v9 user: 10->11 must carry it into the new signatures table.
|
||||
9 -> execSQL(
|
||||
"UPDATE account_settings SET signature = 'Cheers,' || char(10) || 'Ada' " +
|
||||
"WHERE accountId = 'acct'",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every mail-cache row cached at v7 must survive to v16 (account tables are checked separately). */
|
||||
private fun SupportSQLiteDatabase.assertVersion7CacheSurvived() {
|
||||
assertEquals(2, count("messages"))
|
||||
assertEquals(1, count("outbox"))
|
||||
assertEquals(1, count("drafts"))
|
||||
query("SELECT folder, subject, isRead FROM messages WHERE id = 'acct:1'").use { c ->
|
||||
assertTrue("message cached at v7 must survive", c.moveToFirst())
|
||||
assertEquals("7->8 files pre-upgrade messages under INBOX", "INBOX", c.getString(0))
|
||||
assertEquals("Analytical engines", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
}
|
||||
query("SELECT filename FROM attachments WHERE messageId = 'acct:1'").use { c ->
|
||||
assertTrue("attachment rows must survive the 6->7 style table rebuilds", c.moveToFirst())
|
||||
assertEquals("notes.pdf", c.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache-table columns/rows the migrations backfill must hold their documented defaults at v16. */
|
||||
private fun SupportSQLiteDatabase.assertMigrationBackfillsApplied() {
|
||||
// 9->10 adds bcc columns defaulting to ''; 10->11 adds nullable bodyHtml.
|
||||
query("SELECT bccAddresses, bodyHtml FROM outbox WHERE id = 'out-1'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("", c.getString(0))
|
||||
assertTrue("bodyHtml must default to null (plaintext-only)", c.isNull(1))
|
||||
}
|
||||
query("SELECT bccAddresses, bodyHtml FROM drafts WHERE id = 'draft-1'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("", c.getString(0))
|
||||
assertTrue(c.isNull(1))
|
||||
}
|
||||
// 11->12 stamps the folder cached at v8 as not special-use.
|
||||
query("SELECT specialUse FROM folders WHERE fullName = 'INBOX'").use { c ->
|
||||
assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst())
|
||||
assertEquals(0, c.getInt(0))
|
||||
}
|
||||
// 14->15 adds a nullable hierarchyDelimiter; the folder cached at v8 predates it, so it reads
|
||||
// null and the drawer infers the separator from the name until the next folder refresh.
|
||||
query("SELECT hierarchyDelimiter FROM folders WHERE fullName = 'INBOX'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertTrue("a folder cached before v15 must read a null delimiter", c.isNull(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account tables' rows + migration backfills must be intact at v15, just before 15->16 moves
|
||||
* them to [AccountDatabase] and drops them (issue #111). AccountDataMigrator's own copy is
|
||||
* exercised in `AccountDataMigratorTest`; here we only assert the source rows reach the move point.
|
||||
*/
|
||||
private fun SupportSQLiteDatabase.assertAccountDataPresentAtV15() {
|
||||
assertEquals(1, count("accounts"))
|
||||
query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("stored credentials must reach v15 before the move", c.moveToFirst())
|
||||
assertEquals("sealed-secret", c.getString(0))
|
||||
}
|
||||
// 8->9 backfills one default settings row per existing account.
|
||||
query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
|
||||
assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst())
|
||||
assertEquals(1, c.getInt(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
}
|
||||
// 10->11 turns the signature written at v9 into that account's default rich-text signature.
|
||||
query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
|
||||
assertEquals("Signature", c.getString(0))
|
||||
assertEquals("Cheers,<br>Ada", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
/** 15->16 drops the account tables from the cache (AccountDataMigrator copies them out first). */
|
||||
private fun SupportSQLiteDatabase.assertAccountTablesDroppedAtV16() {
|
||||
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
|
||||
query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '$table'").use { c ->
|
||||
assertFalse("15->16 must drop `$table` from the cache database", c.moveToFirst())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c ->
|
||||
c.moveToFirst()
|
||||
c.getInt(0)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
|
||||
/** The oldest schema in app/schemas; earlier versions predate schema export. */
|
||||
const val OLDEST_EXPORTED_SCHEMA = 7
|
||||
|
||||
/**
|
||||
* Every migration the app ships, pulled from Migrations.kt's file facade so a newly added
|
||||
* migration is replayed automatically. DatabaseModule.provideDatabase registers this same
|
||||
* set of top-level vals.
|
||||
*/
|
||||
val allAppMigrations: List<Migration> = run {
|
||||
val migrationsFile = checkNotNull(MIGRATION_1_2.javaClass.enclosingClass) {
|
||||
"expected MIGRATION_1_2 to be a top-level val in Migrations.kt"
|
||||
}
|
||||
migrationsFile.methods
|
||||
.filter { Modifier.isStatic(it.modifiers) && it.parameterCount == 0 }
|
||||
.filter { Migration::class.java.isAssignableFrom(it.returnType) }
|
||||
.map { it.invoke(null) as Migration }
|
||||
.sortedBy { it.startVersion }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package org.libremail.mail
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import jakarta.mail.Session
|
||||
import java.util.Properties
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Properties
|
||||
|
||||
/**
|
||||
* Guards the main "Jakarta/Angus Mail on Android" risk: that provider registration
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.notifications
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Locks in the notification deep-link contract: a message id round-trips build → parse, and intents
|
||||
* for different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys
|
||||
* on — so per-message notifications never collapse onto one shared PendingIntent.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationIntentsTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Test
|
||||
fun message_id_round_trips_through_the_intent() {
|
||||
val id = "imap:user@example.com:INBOX:42"
|
||||
assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uri_hostile_ids_round_trip() {
|
||||
val id = "imap:user@example.com:[Gmail]/All Mail:7?&%#"
|
||||
assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun other_intents_carry_no_message_id() {
|
||||
assertNull(NotificationIntents.messageId(null))
|
||||
assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_MAIN)))
|
||||
assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intents_for_different_messages_are_distinct_pending_intent_keys() {
|
||||
val first = NotificationIntents.openMessage(context, "imap:a@b:INBOX:1")
|
||||
val second = NotificationIntents.openMessage(context, "imap:a@b:INBOX:2")
|
||||
assertFalse(first.filterEquals(second))
|
||||
assertTrue(first.filterEquals(NotificationIntents.openMessage(context, "imap:a@b:INBOX:1")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.push
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Exercises the real [BatteryOptimizationManager.candidateIntents]/[BatteryOptimizationManager.settingsIntent]
|
||||
* against a real `Context`/`PackageManager` (#150): `Intent`/`Uri`/`PackageManager.resolveActivity` are
|
||||
* unmocked SDK stubs off-device, so this on-device coverage is the only place the actual candidate
|
||||
* actions, order, and package scoping can be checked directly. The fallback-selection logic itself
|
||||
* (which candidate wins, and that there's always a last resort) is covered Android-free by
|
||||
* `BatteryOptimizationManagerTest` in the `test` source set; end-to-end launch-from-the-onboarding-
|
||||
* button coverage lives in `BatteryOptimizationStepTest`.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BatteryOptimizationManagerIntentTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val manager = BatteryOptimizationManager(context)
|
||||
|
||||
@Test
|
||||
fun candidateIntents_tryAppDetailsFirst_thenTheBatteryOptimizationList() {
|
||||
val candidates = manager.candidateIntents()
|
||||
|
||||
assertEquals(2, candidates.size)
|
||||
assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, candidates[0].action)
|
||||
assertEquals(Uri.fromParts("package", context.packageName, null), candidates[0].data)
|
||||
assertEquals(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, candidates[1].action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsIntent_landsOnAppDetails_becauseItAlwaysResolvesOnARealDevice() {
|
||||
// Every real/emulated Android device ships a Settings app that resolves app-details for any
|
||||
// installed package, so the primary (most direct) candidate always wins here. The fallback
|
||||
// exists for devices this test environment can't represent (see the class KDoc).
|
||||
val intent = manager.settingsIntent()
|
||||
|
||||
assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, intent.action)
|
||||
assertEquals(Uri.fromParts("package", context.packageName, null), intent.data)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui
|
||||
|
||||
import java.io.File
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.libremail.data.sync.Syncer
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.Folder
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.InlineImage
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.OutboxMessage
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.domain.model.UnreadCount
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* In-memory [AccountRepository] for Compose UI tests: serves a fixed account list, records the
|
||||
@@ -38,6 +44,11 @@ class FakeAccountRepository(
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
|
||||
addedAccount = account
|
||||
addedPassword = password
|
||||
// Mirror the real repository: a successful add makes the account observable, so screens that
|
||||
// react to the account list (e.g. the mailbox after onboarding) see it appear.
|
||||
if (result.isSuccess) {
|
||||
accountsFlow.value = accountsFlow.value.filterNot { it.id == account.id } + account
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -50,6 +61,8 @@ class FakeAccountRepository(
|
||||
override suspend fun deleteAccount(id: String) {
|
||||
accountsFlow.value = accountsFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override suspend fun resetBackfillProgress(accountId: String?) = Unit
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,28 +71,99 @@ class FakeAccountRepository(
|
||||
*/
|
||||
class FakeMailRepository(
|
||||
var sendResult: Result<Unit> = Result.success(Unit),
|
||||
private val messages: List<Message> = emptyList(),
|
||||
private val folders: List<Folder> = emptyList(),
|
||||
private val attachments: List<Attachment> = emptyList(),
|
||||
private val downloadedParts: Set<Int> = emptySet(),
|
||||
private val unreadCounts: List<UnreadCount> = emptyList(),
|
||||
) : MailRepository {
|
||||
|
||||
val sentMessages = mutableListOf<OutgoingMessage>()
|
||||
val savedDrafts = mutableListOf<Draft>()
|
||||
val deletedDraftIds = mutableListOf<String>()
|
||||
val archivedIds = mutableListOf<List<String>>()
|
||||
val spammedIds = mutableListOf<List<String>>()
|
||||
val trashedIds = mutableListOf<List<String>>()
|
||||
val expungedIds = mutableListOf<List<String>>()
|
||||
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
|
||||
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> = flowOf(emptyList())
|
||||
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
|
||||
flowOf(messages.filter { it.accountId == accountId && it.folder == folder })
|
||||
|
||||
override suspend fun getMessage(id: String): Message? = null
|
||||
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
|
||||
flowOf(messages.filter { it.folder == folder })
|
||||
|
||||
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> =
|
||||
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
|
||||
|
||||
override fun observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
|
||||
folders.filter {
|
||||
it.accountId ==
|
||||
accountId
|
||||
},
|
||||
)
|
||||
|
||||
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = flowOf(unreadCounts)
|
||||
|
||||
override suspend fun refreshFolders(accountId: String): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id }
|
||||
|
||||
override suspend fun openMessage(id: String): Result<Message> =
|
||||
Result.failure(UnsupportedOperationException("not used in UI tests"))
|
||||
messages.firstOrNull { it.id == id }?.let { Result.success(it) }
|
||||
?: Result.failure(NoSuchElementException("no message $id"))
|
||||
|
||||
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(emptyList())
|
||||
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(
|
||||
attachments.filter {
|
||||
it.messageId ==
|
||||
messageId
|
||||
},
|
||||
)
|
||||
|
||||
override suspend fun inlineImages(messageId: String): List<InlineImage> = emptyList()
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
|
||||
Result.failure(UnsupportedOperationException("not used in UI tests"))
|
||||
|
||||
override suspend fun prefetchMessage(messageId: String): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun downloadedAttachmentParts(messageId: String): Set<Int> = downloadedParts
|
||||
|
||||
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun deleteMessage(id: String): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun archive(ids: List<String>): Result<Unit> {
|
||||
archivedIds += ids
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun reportSpam(ids: List<String>): Result<Unit> {
|
||||
spammedIds += ids
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun trash(ids: List<String>): Result<Unit> {
|
||||
trashedIds += ids
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun expunge(ids: List<String>): Result<Unit> {
|
||||
expungedIds += ids
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun moveToFolder(ids: List<String>, destFolderFullName: String): Result<Unit> {
|
||||
movedToFolder += ids to destFolderFullName
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result<String> {
|
||||
replyDrafts += messageId to mode
|
||||
return Result.success("draft-$messageId")
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> {
|
||||
sentMessages += outgoing
|
||||
return sendResult
|
||||
@@ -103,7 +187,14 @@ class FakeMailRepository(
|
||||
|
||||
override suspend fun retryOutbox() {}
|
||||
|
||||
override suspend fun searchServer(query: String) {}
|
||||
override suspend fun searchServer(query: String, accountId: String?, folder: String) {}
|
||||
|
||||
override suspend fun clearSearchResults() {}
|
||||
}
|
||||
|
||||
/** No-op [Syncer] for UI tests: the screen renders cached data, so sync calls do nothing. */
|
||||
class FakeMailSyncer : Syncer {
|
||||
override suspend fun syncAll(): Result<Int> = Result.success(0)
|
||||
override suspend fun syncAccount(accountId: String): Result<Int> = Result.success(0)
|
||||
override suspend fun syncFolder(accountId: String, folder: String): Result<Int> = Result.success(0)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class ManualSetupScreenTest {
|
||||
// Build the view model once and capture it, so recomposition doesn't recreate it.
|
||||
private fun setContent(
|
||||
repository: FakeAccountRepository = FakeAccountRepository(),
|
||||
onAccountAdded: () -> Unit = {},
|
||||
onAccountAdded: (String) -> Unit = {},
|
||||
) {
|
||||
val viewModel = ManualSetupViewModel(repository)
|
||||
composeTestRule.setContent {
|
||||
|
||||
@@ -3,23 +3,34 @@ package org.libremail.ui.compose
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.contacts.ContactsRepository
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
@@ -47,12 +58,20 @@ class ComposeScreenTest {
|
||||
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private var db: AccountDatabase? = null
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db?.close()
|
||||
}
|
||||
|
||||
@Before
|
||||
fun grantContactsPermission() {
|
||||
// ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test
|
||||
// calls setContent) so no system permission dialog appears to block the headless run.
|
||||
// ComposeScreen no longer requests READ_CONTACTS (the request moved to onboarding/#127); it
|
||||
// only reads the current grant on resume. Pre-grant it (before setContent) so contactsAllowed
|
||||
// resolves true and the autocomplete path stays exercised — no system dialog is ever shown.
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
instrumentation.uiAutomation.grantRuntimePermission(
|
||||
instrumentation.targetContext.packageName,
|
||||
@@ -61,16 +80,17 @@ class ComposeScreenTest {
|
||||
}
|
||||
|
||||
// Build the view model once and capture it, so recomposition doesn't recreate it.
|
||||
private fun setContent(
|
||||
mailRepository: FakeMailRepository = FakeMailRepository(),
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
private fun setContent(mailRepository: FakeMailRepository = FakeMailRepository(), onBack: () -> Unit = {}) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val database = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build().also { db = it }
|
||||
val viewModel = ComposeViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
mailRepository = mailRepository,
|
||||
accountRepository = FakeAccountRepository(accounts = listOf(account)),
|
||||
contactsRepository = ContactsRepository(context),
|
||||
accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()),
|
||||
signatureRepository = SignatureRepository(database.signatureDao()),
|
||||
settingsRepository = SettingsRepository(context),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
@@ -106,4 +126,105 @@ class ComposeScreenTest {
|
||||
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun send_whenBodyMentionsAttachmentWithoutOne_promptsBeforeSending() {
|
||||
val mailRepository = FakeMailRepository()
|
||||
setContent(mailRepository)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("I attached the report")
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
|
||||
// "Yes" returns to composing: the dialog closes and nothing is sent.
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_yes)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertDoesNotExist()
|
||||
assertTrue(mailRepository.sentMessages.isEmpty())
|
||||
|
||||
// Sending again and answering "No" delivers the message as-is.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_no)).performClick()
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
|
||||
assertEquals("I attached the report", mailRepository.sentMessages.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ccAndBcc_startCollapsed_expandViaLinksAndCarryThroughSend() {
|
||||
val mailRepository = FakeMailRepository()
|
||||
setContent(mailRepository)
|
||||
|
||||
// Collapsed: the Cc/Bcc labels exist only as links, not as editable fields.
|
||||
editableField(R.string.compose_cc).assertDoesNotExist()
|
||||
editableField(R.string.compose_bcc).assertDoesNotExist()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_cc)).performClick()
|
||||
editableField(R.string.compose_cc).performTextInput("cc@example.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_bcc)).performClick()
|
||||
editableField(R.string.compose_bcc).performTextInput("bcc@example.com")
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
|
||||
val sent = mailRepository.sentMessages.single()
|
||||
assertEquals("cc@example.com", sent.cc)
|
||||
assertEquals("bcc@example.com", sent.bcc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml() {
|
||||
val mailRepository = FakeMailRepository()
|
||||
setContent(mailRepository)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Buy milk")
|
||||
// The bullet-list button is deliberately chosen over the inline styles (bold/italic): block
|
||||
// markers apply to the caret's whole line, so the end-of-text caret that performTextInput
|
||||
// leaves is enough - no on-device range selection (which is unreliable in instrumented tests)
|
||||
// is needed to prove that a toolbar tap flows real formatting into the sent message's HTML.
|
||||
// "•" is the bullet button's own (untranslated) glyph label - see FormattingToolbar.
|
||||
composeTestRule.onNodeWithText("•").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
|
||||
val sent = mailRepository.sentMessages.single()
|
||||
// Plaintext keeps the readable "• " marker; the HTML part carries the real <ul>/<li> structure.
|
||||
assertEquals("• Buy milk", sent.body)
|
||||
assertTrue(
|
||||
"expected bullet-list html, got ${sent.bodyHtml}",
|
||||
sent.bodyHtml?.contains("<ul><li>Buy milk</li></ul>") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formattingToolbar_buttonsCarryOnClickLabelsForAccessibility() {
|
||||
setContent()
|
||||
|
||||
// Every toolbar button (see FormattingToolbar in RichTextEditor.kt) is a plain clickable Box with
|
||||
// a bare glyph Text as its only visible content, so TalkBack relies entirely on the click action's
|
||||
// label (there is no separate contentDescription) to announce what the button does.
|
||||
val buttons = listOf(
|
||||
"B" to R.string.format_bold,
|
||||
"I" to R.string.format_italic,
|
||||
"U" to R.string.format_underline,
|
||||
"•" to R.string.format_bullet_list,
|
||||
"1." to R.string.format_numbered_list,
|
||||
"❝" to R.string.format_quote,
|
||||
"🔗" to R.string.format_link,
|
||||
)
|
||||
buttons.forEach { (glyph, descriptionRes) ->
|
||||
val config = composeTestRule.onNodeWithText(glyph).fetchSemanticsNode().config
|
||||
val clickLabel = if (config.contains(SemanticsActions.OnClick)) {
|
||||
config[SemanticsActions.OnClick].label
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val message = "toolbar button \"$glyph\" is missing its accessibility label"
|
||||
assertEquals(message, string(descriptionRes), clickLabel)
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */
|
||||
private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI tests for the compose formatting toolbar's font-family picker (#72). [FontPicker] is
|
||||
* presentational, so it is driven in isolation - independent of the surrounding
|
||||
* [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `FontSizePickerTest`
|
||||
* exercises its dropdown.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FontPickerTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(selectedCss: String?, onSelect: (String?) -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
FontPicker(selectedCss = selectedCss, onSelect = onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noFontSelected_buttonShowsDefaultLabel() {
|
||||
setContent(selectedCss = null)
|
||||
composeTestRule.onNodeWithText(string(R.string.format_font_default)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFontSelected_buttonShowsItsDisplayName() {
|
||||
val inter = FontRegistry.choices.first { it.name == "Inter" }
|
||||
setContent(selectedCss = inter.css)
|
||||
composeTestRule.onNodeWithText("Inter").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingTheButton_opensAMenuListingEveryRegistryFont() {
|
||||
setContent(selectedCss = null)
|
||||
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
|
||||
FontRegistry.choices.forEach { choice ->
|
||||
composeTestRule.onNodeWithText(choice.name).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickingAFontFromTheMenu_reportsItsCssStack() {
|
||||
var picked: String? = "unset"
|
||||
val lora = FontRegistry.choices.first { it.name == "Lora" }
|
||||
setContent(selectedCss = null) { picked = it }
|
||||
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
|
||||
composeTestRule.onNodeWithText("Lora").performClick()
|
||||
assertEquals(lora.css, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickingDefaultFromTheMenu_clearsBySelectingNull() {
|
||||
var picked: String? = "unset"
|
||||
val inter = FontRegistry.choices.first { it.name == "Inter" }
|
||||
// The button reads "Inter" here, so the menu's own "Default" entry is the only such match.
|
||||
setContent(selectedCss = inter.css) { picked = it }
|
||||
composeTestRule.onNodeWithText("Inter").performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
|
||||
assertEquals(null, picked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI tests for the compose formatting toolbar's font-size dropdown (#73). [FontSizePicker] is
|
||||
* presentational, so it is driven directly - independent of the surrounding
|
||||
* [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `ContactAutocompleteRowTest`
|
||||
* exercises its row composable in isolation.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FontSizePickerTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
|
||||
|
||||
private fun setContent(selectedPt: Int?, onSelect: (Int?) -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
FontSizePicker(selectedPt = selectedPt, onSelect = onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSizeSelected_buttonShowsDefaultLabel() {
|
||||
setContent(selectedPt = null)
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_default)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSizeSelected_buttonShowsItsPointValue() {
|
||||
setContent(selectedPt = 18)
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 18)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingTheButton_opensAMenuListingDefaultAndEveryPreset() {
|
||||
setContent(selectedPt = null)
|
||||
// Before the menu opens, "Default" only labels the anchor button itself - a unique match.
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
|
||||
FONT_SIZE_PRESETS_PT.forEach { pt ->
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_pt, pt)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickingAPresetFromTheMenu_reportsItsPointSize() {
|
||||
var picked: Int? = -1
|
||||
setContent(selectedPt = null) { picked = it }
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 14)).performClick()
|
||||
assertEquals(14, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickingDefaultFromTheMenu_clearsBySelectingNull() {
|
||||
var picked: Int? = 12
|
||||
setContent(selectedPt = 12) { picked = it }
|
||||
// The button reads "12 pt" here, so the menu's own "Default" entry is the only such match.
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 12)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
|
||||
assertEquals(null, picked)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.richtext.RichAlign
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI tests for the compose formatting toolbar's three-state paragraph-alignment control (#76).
|
||||
* [ParagraphAlignmentControl] is presentational, so it is driven in isolation - independent of the
|
||||
* surrounding [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `FontSizePickerTest`
|
||||
* exercises its picker.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ParagraphAlignmentControlTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun setContent(selected: RichAlign?, onSelect: (RichAlign) -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ParagraphAlignmentControl(selected = selected, onSelect = onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsAllThreeAlignmentGlyphs() {
|
||||
setContent(selected = RichAlign.START)
|
||||
composeTestRule.onNodeWithText(ALIGN_START_GLYPH).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(ALIGN_END_GLYPH).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCenter_reportsCenter() {
|
||||
var picked: RichAlign? = null
|
||||
setContent(selected = RichAlign.START) { picked = it }
|
||||
composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).performClick()
|
||||
assertEquals(RichAlign.CENTER, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingEnd_reportsEnd() {
|
||||
var picked: RichAlign? = null
|
||||
setContent(selected = RichAlign.START) { picked = it }
|
||||
composeTestRule.onNodeWithText(ALIGN_END_GLYPH).performClick()
|
||||
assertEquals(RichAlign.END, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingStart_reportsStart() {
|
||||
var picked: RichAlign? = null
|
||||
setContent(selected = RichAlign.CENTER) { picked = it }
|
||||
composeTestRule.onNodeWithText(ALIGN_START_GLYPH).performClick()
|
||||
assertEquals(RichAlign.START, picked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.mailbox
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.Folder
|
||||
import org.libremail.domain.model.FolderRole
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/** UI test for the navigation drawer: folder rendering, friendly names, the account switcher, and taps. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FolderDrawerTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private val alice = account("imap:a", "alice@example.org")
|
||||
private val bob = account("imap:b", "bob@example.org")
|
||||
|
||||
@Test
|
||||
fun singleAccount_rendersStandardFoldersWithFriendlyNames() {
|
||||
setContent(
|
||||
accounts = listOf(alice),
|
||||
drawerAccount = alice,
|
||||
folders = listOf(
|
||||
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT),
|
||||
// Give ARCHIVE a server name that differs from its friendly label so the assertion
|
||||
// below actually discriminates a role-to-label regression (displayName != friendly).
|
||||
folder("imap:a", "[Gmail]/All Mail", "All Mail", FolderRole.ARCHIVE),
|
||||
folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed()
|
||||
// Standard folders use the friendly role name, not the raw server name — verified for both
|
||||
// Sent ("Sent Mail" -> "Sent") and Archive ("All Mail" -> "Archive").
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("All Mail").assertDoesNotExist()
|
||||
// Normal folders keep their server name.
|
||||
composeTestRule.onNodeWithText("Receipts").assertIsDisplayed()
|
||||
// A single account shows no account switcher / "All Inboxes" entry.
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateFolderNames_areDisambiguatedWithProviderSuffix() {
|
||||
val gmail = account("imap:g", "user@gmail.com").copy(
|
||||
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
|
||||
)
|
||||
setContent(
|
||||
accounts = listOf(gmail),
|
||||
drawerAccount = gmail,
|
||||
folders = listOf(
|
||||
folder("imap:g", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
// Gmail's built-in Drafts (server special-use) alongside a same-named user folder.
|
||||
folder("imap:g", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, specialUse = true),
|
||||
folder("imap:g", "Drafts", "Drafts", FolderRole.DRAFTS),
|
||||
),
|
||||
)
|
||||
|
||||
// The provider's built-in folder is suffixed; the user folder keeps the plain name.
|
||||
composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Drafts").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountSwitchGap_staleFolderListKeepsItsOwnProviderSuffix() {
|
||||
val gmail = account("imap:g", "user@gmail.com").copy(
|
||||
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
|
||||
)
|
||||
val outlook = account("imap:o", "user@outlook.com").copy(
|
||||
authType = AuthType.OAUTH_OUTLOOK,
|
||||
imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS),
|
||||
)
|
||||
// The transient frame from issue #61: the drawer account has already switched to Outlook,
|
||||
// but the folder list still holds the Gmail account's folders until its query emits.
|
||||
setContent(
|
||||
accounts = listOf(gmail, outlook),
|
||||
drawerAccount = outlook,
|
||||
folders = listOf(
|
||||
folder("imap:g", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
folder("imap:g", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, specialUse = true),
|
||||
folder("imap:g", "Drafts", "Drafts", FolderRole.DRAFTS),
|
||||
),
|
||||
)
|
||||
|
||||
// The de-dup suffix derives from the folders' own account — never the incoming account.
|
||||
composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Drafts - Outlook").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAFolder_reportsItsAccountAndFullName() {
|
||||
var picked: Pair<String, String>? = null
|
||||
setContent(
|
||||
accounts = listOf(alice),
|
||||
drawerAccount = alice,
|
||||
folders = listOf(
|
||||
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
|
||||
),
|
||||
onSelectFolder = { accountId, fullName -> picked = accountId to fullName },
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_archive)).performClick()
|
||||
|
||||
assertEquals("imap:a" to "Archive", picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleAccounts_showSwitcherAndAllInboxesEntry() {
|
||||
var unifiedTapped = false
|
||||
setContent(
|
||||
accounts = listOf(alice, bob),
|
||||
drawerAccount = alice,
|
||||
folders = listOf(folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX)),
|
||||
onSelectUnifiedInbox = { unifiedTapped = true },
|
||||
)
|
||||
|
||||
// The switcher shows the active drawer account, and the unified entry is available.
|
||||
composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).performClick()
|
||||
assertTrue(unifiedTapped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun folderWithUnreadMail_showsCountBadge_andReadFolderShowsNone() {
|
||||
setContent(
|
||||
accounts = listOf(alice),
|
||||
drawerAccount = alice,
|
||||
folders = listOf(
|
||||
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
|
||||
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
|
||||
),
|
||||
folderUnreadCounts = mapOf("INBOX" to 3),
|
||||
)
|
||||
|
||||
// The inbox badge announces its exact count for screen readers.
|
||||
val threeUnread = composeTestRule.activity.resources
|
||||
.getQuantityString(R.plurals.folder_unread_count_description, 3, 3)
|
||||
composeTestRule.onNodeWithContentDescription(threeUnread).assertIsDisplayed()
|
||||
// Archive has no unread mail, so no badge is rendered for it.
|
||||
val oneUnread = composeTestRule.activity.resources
|
||||
.getQuantityString(R.plurals.folder_unread_count_description, 1, 1)
|
||||
composeTestRule.onNodeWithContentDescription(oneUnread).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun setContent(
|
||||
accounts: List<Account>,
|
||||
drawerAccount: Account?,
|
||||
folders: List<Folder>,
|
||||
folderUnreadCounts: Map<String, Int> = emptyMap(),
|
||||
accountsWithUnread: Set<String> = emptySet(),
|
||||
selectedAccountId: String? = null,
|
||||
selectedFolder: String = "INBOX",
|
||||
onSelectUnifiedInbox: () -> Unit = {},
|
||||
onSelectFolder: (String, String) -> Unit = { _, _ -> },
|
||||
onSelectDrawerAccount: (String) -> Unit = {},
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ModalDrawerSheet {
|
||||
FolderDrawer(
|
||||
accounts = accounts,
|
||||
drawerAccount = drawerAccount,
|
||||
folders = folders,
|
||||
folderUnreadCounts = folderUnreadCounts,
|
||||
accountsWithUnread = accountsWithUnread,
|
||||
selectedAccountId = selectedAccountId,
|
||||
selectedFolder = selectedFolder,
|
||||
onSelectUnifiedInbox = onSelectUnifiedInbox,
|
||||
onSelectFolder = onSelectFolder,
|
||||
onSelectDrawerAccount = onSelectDrawerAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun account(id: String, email: String) = Account(
|
||||
id = id,
|
||||
email = email,
|
||||
displayName = email,
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private fun folder(
|
||||
accountId: String,
|
||||
fullName: String,
|
||||
displayName: String,
|
||||
role: FolderRole,
|
||||
specialUse: Boolean = false,
|
||||
) = Folder(accountId, fullName, displayName, role, selectable = true, specialUse = specialUse)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.mailbox
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.longClick
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.Folder
|
||||
import org.libremail.domain.model.FolderRole
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.FakeMailSyncer
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for the mailbox's long-press contextual action bar. Drives the real
|
||||
* [MailboxScreen] + [MailboxViewModel] backed by in-memory fakes.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MailboxScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
private fun count(n: Int) = composeTestRule.activity.getString(R.string.cab_selected_count, n)
|
||||
|
||||
private val account = Account(
|
||||
id = "imap:a",
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private val gmailAccount = account.copy(
|
||||
email = "a@gmail.com",
|
||||
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
/** A Gmail-style tree where the built-in Drafts collides with a same-named user folder. */
|
||||
private val duplicateDraftsFolders = listOf(
|
||||
Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true),
|
||||
Folder("imap:a", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, selectable = true, specialUse = true),
|
||||
Folder("imap:a", "Drafts", "Drafts", FolderRole.DRAFTS, selectable = true),
|
||||
)
|
||||
|
||||
private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message(
|
||||
id = "imap:a:$folder:$uid",
|
||||
accountId = "imap:a",
|
||||
sender = "Sender $uid",
|
||||
senderEmail = "s$uid@example.org",
|
||||
subject = subject,
|
||||
snippet = "",
|
||||
body = "",
|
||||
isHtml = false,
|
||||
timestampMillis = 1_000L,
|
||||
isRead = true,
|
||||
isStarred = false,
|
||||
folder = folder,
|
||||
inInbox = true,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
private fun setContent(repo: FakeMailRepository, activeAccount: Account = account): MailboxViewModel {
|
||||
val viewModel = MailboxViewModel(
|
||||
repo,
|
||||
FakeAccountRepository(accounts = listOf(activeAccount)),
|
||||
FakeMailSyncer(),
|
||||
SavedStateHandle(),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
MailboxScreen(
|
||||
onOpenMessage = {},
|
||||
onCompose = {},
|
||||
onOpenDrafts = {},
|
||||
onOpenOutbox = {},
|
||||
onAddAccount = {},
|
||||
onOpenCompose = {},
|
||||
onSelectTab = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longPress_entersSelection_andCountTracksTaps() {
|
||||
setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))))
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithText(count(1)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
composeTestRule.onNodeWithText(count(2)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overflow_showsReplyActions_forSingleSelection() {
|
||||
setContent(FakeMailRepository(messages = listOf(message("1", "First"))))
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overflow_hidesReplyActions_forMultiSelection() {
|
||||
setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))))
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveIcon_isDirect_andArchivesTheSelection() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
// A direct icon button — no trip through the overflow menu.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() }
|
||||
assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spamIcon_isDirect_andConfirmsBeforeReporting() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() }
|
||||
assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveIcon_hides_whileViewingTheArchiveFolder() {
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message("1", "Old news", folder = "Archive")),
|
||||
folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)),
|
||||
)
|
||||
val viewModel = setContent(repo)
|
||||
viewModel.selectFolder("imap:a", "Archive")
|
||||
waitForText("Old news")
|
||||
|
||||
composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() }
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_confirmsMoveToTrash_thenTrashesViaRepository() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_trash_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.trashedIds.isNotEmpty() }
|
||||
assertEquals(listOf("imap:a:INBOX:1"), repo.trashedIds.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun move_picker_movesSelectionToTheChosenFolder() {
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message("1", "First")),
|
||||
folders = listOf(
|
||||
Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true),
|
||||
Folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL, selectable = true),
|
||||
),
|
||||
)
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
|
||||
// The off-screen navigation drawer also lists "Receipts", so scope the tap to the move dialog.
|
||||
composeTestRule.onNode(hasText("Receipts") and hasAnyAncestor(isDialog())).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.movedToFolder.isNotEmpty() }
|
||||
assertEquals("Receipts", repo.movedToFolder.first().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movePicker_disambiguatesDuplicateNames_andMovesToTheChosenFolder() {
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message("1", "First")),
|
||||
folders = duplicateDraftsFolders,
|
||||
)
|
||||
setContent(repo, activeAccount = gmailAccount)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
|
||||
|
||||
// Issue #59: the two same-named Drafts folders are told apart in the picker, and choosing
|
||||
// the suffixed row files into the provider's built-in folder — not the user folder.
|
||||
composeTestRule.onNode(hasText("Drafts") and hasAnyAncestor(isDialog())).assertIsDisplayed()
|
||||
composeTestRule.onNode(hasText("Drafts - Gmail") and hasAnyAncestor(isDialog())).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.movedToFolder.isNotEmpty() }
|
||||
assertEquals("[Gmail]/Drafts", repo.movedToFolder.first().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appBarTitle_keepsTheDisambiguatedFolderLabel() {
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message("1", "First")),
|
||||
folders = duplicateDraftsFolders,
|
||||
)
|
||||
setContent(repo, activeAccount = gmailAccount)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.drawer_open)).performClick()
|
||||
composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Drafts - Gmail").performClick()
|
||||
|
||||
// Issue #59: the app-bar title keeps the drawer's de-duplicated label instead of collapsing
|
||||
// to an ambiguous "Drafts". Once selected, the label renders twice — the app-bar title plus
|
||||
// the (composed but closed) drawer's entry.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Drafts - Gmail").fetchSemanticsNodes().size == 2
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineIndicator_showsForCachedMessages() {
|
||||
setContent(FakeMailRepository(messages = listOf(message("1", "Cached", bodyFetched = true))))
|
||||
waitForText("Cached")
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.message_available_offline)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.hamcrest.CoreMatchers.allOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.contacts.ContactsPermissionManager
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end test for the onboarding battery opt-in step (#49). It drives the real
|
||||
* [BatteryOptimizationScreen] + graph-scoped [OnboardingViewModel] through a NavHost that mirrors the
|
||||
* production "add another? → (optional) battery → inbox" tail (see
|
||||
* `LibreMailApp.onboardingFinishDestinations`).
|
||||
*
|
||||
* Battery status comes from the real [BatteryOptimizationManager]: a fresh emulator is never on the
|
||||
* battery allowlist, so the step is offered. The "already unrestricted" skip can't be forced from a
|
||||
* test (there's no API to set it) and is covered by the view-model unit tests; the "already handled"
|
||||
* skip is exercised here through the real settings DataStore.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BatteryOptimizationStepTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private lateinit var settingsRepository: SettingsRepository
|
||||
private lateinit var onboarding: OnboardingViewModel
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "add another? → battery → inbox" tail with one account already added this session,
|
||||
* starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the
|
||||
* skip path can be exercised through the real repository.
|
||||
*/
|
||||
private fun setContent(handled: Boolean) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
settingsRepository = SettingsRepository(context)
|
||||
runBlocking { settingsRepository.setBatteryPromptHandled(handled) }
|
||||
onboarding = OnboardingViewModel(
|
||||
BatteryOptimizationManager(context),
|
||||
ContactsPermissionManager(context),
|
||||
settingsRepository,
|
||||
)
|
||||
onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
|
||||
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
composable(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
|
||||
AddAnotherAccountScreen(
|
||||
onAddAnother = {},
|
||||
onFinish = {
|
||||
if (batteryPromptNeeded == true) {
|
||||
navController.navigate(Routes.ONBOARDING_BATTERY)
|
||||
} else {
|
||||
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
|
||||
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_BATTERY) {
|
||||
BatteryOptimizationScreen(
|
||||
viewModel = onboarding,
|
||||
onFinish = {
|
||||
onboarding.markBatteryPromptHandled()
|
||||
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
|
||||
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.MAILBOX_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(INBOX_MARKER)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batteryStep_isOffered_thenNotNow_landsOnInbox() {
|
||||
setContent(handled = false)
|
||||
// The decision resolves asynchronously (a DataStore read); wait before driving the finish tap.
|
||||
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// The battery opt-in step is shown...
|
||||
waitForText(string(R.string.onboarding_battery_title))
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
|
||||
|
||||
// ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag).
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick()
|
||||
waitForText(INBOX_MARKER)
|
||||
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
||||
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batteryStep_takeMeThere_opensThisAppsSystemSettings() {
|
||||
setContent(handled = false)
|
||||
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
waitForText(string(R.string.onboarding_battery_title))
|
||||
|
||||
val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
|
||||
Intents.init()
|
||||
try {
|
||||
// Stub the match so the real system settings screen never actually launches mid-test.
|
||||
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
|
||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick()
|
||||
|
||||
// Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
|
||||
Intents.intended(
|
||||
allOf(
|
||||
hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS),
|
||||
hasData(Uri.fromParts("package", packageName, null)),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
Intents.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finish_skipsBatteryStep_whenAlreadyHandled() {
|
||||
setContent(handled = true)
|
||||
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false }
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// Straight to the inbox — the opt-in step is skipped entirely.
|
||||
waitForText(INBOX_MARKER)
|
||||
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INBOX_MARKER = "INBOX-REACHED"
|
||||
const val FIRST_ACCOUNT_ID = "imap:e2e@example.com"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI tests for the onboarding contacts-access step (#127, #128). They drive the presentational
|
||||
* [ContactsAccessContent] with explicit signals so the three paths — skip, grant (the "done" state),
|
||||
* and request (with the re-ask rationale) — run deterministically without a live system permission
|
||||
* dialog (whose grant state would otherwise leak across the shared instrumentation process).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ContactsAccessStepTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(
|
||||
granted: Boolean,
|
||||
showRationale: Boolean = false,
|
||||
onAllow: () -> Unit = {},
|
||||
onSkip: () -> Unit = {},
|
||||
onContinue: () -> Unit = {},
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ContactsAccessContent(
|
||||
granted = granted,
|
||||
showRationale = showRationale,
|
||||
onAllow = onAllow,
|
||||
onSkip = onSkip,
|
||||
onContinue = onContinue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notGranted_notNow_skipsTheStep() {
|
||||
var skipped = false
|
||||
var allowed = false
|
||||
setContent(granted = false, onAllow = { allowed = true }, onSkip = { skipped = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).performClick()
|
||||
|
||||
assertTrue("Not now must invoke the skip callback", skipped)
|
||||
assertFalse("Skipping must not request the permission", allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notGranted_allow_triggersTheRequest() {
|
||||
var allowed = false
|
||||
setContent(granted = false, onAllow = { allowed = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).performClick()
|
||||
|
||||
assertTrue("Allow must trigger the permission request", allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun granted_showsDoneState_andContinues() {
|
||||
var continued = false
|
||||
setContent(granted = true, onContinue = { continued = true })
|
||||
|
||||
// The "done" copy is shown and the request/skip buttons are gone.
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_done_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).assertDoesNotExist()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_continue)).performClick()
|
||||
assertTrue("Continue must invoke the continue callback", continued)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRequest_showsRationale() {
|
||||
setContent(granted = false, showRationale = true)
|
||||
|
||||
// A re-request explains itself (shouldShowRequestPermissionRationale handling, #128).
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_rationale)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* Component test for [LicenseScreen] (#172), in isolation from the real onboarding nav graph (that
|
||||
* wiring — persisting acceptance, popping the license off the back stack, calling `finish()` on
|
||||
* Decline — belongs to `onboardingGraph()` in `LibreMailApp.kt`, not this composable). This test only
|
||||
* owns the screen's own contract: Agree is gated on having scrolled to the end, Decline (and back)
|
||||
* always works, and the real bundled GPL-3.0 text actually renders.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LicenseScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(onAgree: () -> Unit = {}, onDecline: () -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
LicenseScreen(onAgree = onAgree, onDecline = onDecline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun licenseText_rendersTheRealBundledGplText() {
|
||||
setContent()
|
||||
|
||||
// Proves res/raw/license.txt actually loaded, not just that some placeholder text exists —
|
||||
// an empty or missing resource would make agreeButton_isDisabledUntilScrolledToTheEnd below
|
||||
// pass for the wrong reason (nothing to scroll).
|
||||
composeTestRule.onNodeWithText("GNU GENERAL PUBLIC LICENSE", substring = true).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun agreeButton_isDisabledUntilScrolledToTheEnd() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.license_agree)).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun agreeButton_scrolledToEnd_becomesEnabledAndInvokesOnAgree() {
|
||||
var agreed = false
|
||||
setContent(onAgree = { agreed = true })
|
||||
|
||||
composeTestRule.onNodeWithTag(LICENSE_SCROLL_END_TAG).performScrollTo()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.license_agree)).assertIsEnabled().performClick()
|
||||
assertTrue(agreed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declineButton_worksWithoutScrolling_andInvokesOnDecline() {
|
||||
var declined = false
|
||||
setContent(onDecline = { declined = true })
|
||||
|
||||
// No scrolling first: Decline must never be gated the way Agree is.
|
||||
composeTestRule.onNodeWithText(string(R.string.license_decline)).assertIsEnabled().performClick()
|
||||
|
||||
assertTrue(declined)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemBack_invokesOnDeclineJustLikeTheButton() {
|
||||
var declined = false
|
||||
setContent(onDecline = { declined = true })
|
||||
|
||||
Espresso.pressBack()
|
||||
|
||||
assertTrue(declined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.contacts.ContactsPermissionManager
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.FakeMailSyncer
|
||||
import org.libremail.ui.accountsetup.AccountPickerScreen
|
||||
import org.libremail.ui.accountsetup.AccountSetupViewModel
|
||||
import org.libremail.ui.accountsetup.AppPasswordSetupScreen
|
||||
import org.libremail.ui.accountsetup.AppPasswordViewModel
|
||||
import org.libremail.ui.mailbox.MailboxScreen
|
||||
import org.libremail.ui.mailbox.MailboxViewModel
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end test of the onboarding flow: a fresh install (no accounts) walks welcome → vendor
|
||||
* picker → app-password setup → "add another?" → the first account's inbox.
|
||||
*
|
||||
* It drives the real onboarding screens + ViewModels through a real [NavHost]. The account backend is
|
||||
* the in-memory [FakeAccountRepository] (a successful add makes the account observable) rather than a
|
||||
* live server — GreenMail-backed connection behaviour is covered by the repository unit tests; this
|
||||
* test owns the cross-screen navigation contract.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
// OnboardingWelcomeScreen requests POST_NOTIFICATIONS when it first composes (#151). On API 33+
|
||||
// that runtime dialog would pop over the test, backgrounding the activity and leaving the compose
|
||||
// rule with "No compose hierarchies found". Pre-grant it so the flow runs uninterrupted; the
|
||||
// permission only exists on API 33+, so below TIRAMISU grant nothing (granting a nonexistent
|
||||
// permission errors on older devices).
|
||||
@get:Rule
|
||||
val notificationPermission: GrantPermissionRule =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
GrantPermissionRule.grant()
|
||||
}
|
||||
|
||||
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
|
||||
|
||||
// Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon
|
||||
// as the text appears, so the happy path is unaffected.
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
private fun inboxMessage() = Message(
|
||||
id = "imap:e2e@gmail.com:INBOX:1",
|
||||
accountId = "imap:e2e@gmail.com",
|
||||
sender = "Welcome",
|
||||
senderEmail = "welcome@gmail.com",
|
||||
subject = "E2E first message",
|
||||
snippet = "",
|
||||
body = "",
|
||||
isHtml = false,
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = "INBOX",
|
||||
inInbox = true,
|
||||
bodyFetched = false,
|
||||
)
|
||||
|
||||
private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) {
|
||||
// Real collaborators are cheap here: the manager just wraps PowerManager and the repository
|
||||
// reads the on-device settings DataStore. This test drives its own nav graph (without the
|
||||
// battery step), so the onboarding view model's battery decision is inert for this flow.
|
||||
//
|
||||
// This hand-rolled graph starts at ONBOARDING_WELCOME directly, deliberately bypassing the
|
||||
// GPL-3.0 license gate (#172) that precedes it in the real onboardingGraph() in
|
||||
// LibreMailApp.kt: this test owns the picker → setup → finish tail, not the license screen's
|
||||
// own contract (scroll-to-agree, decline-exits, hard-gating), which LicenseScreenTest covers
|
||||
// in isolation instead.
|
||||
val appContext = composeTestRule.activity.applicationContext
|
||||
val onboarding = OnboardingViewModel(
|
||||
BatteryOptimizationManager(appContext),
|
||||
ContactsPermissionManager(appContext),
|
||||
SettingsRepository(appContext),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val outlookAuthManager = remember { OutlookAuthManager(context) }
|
||||
NavHost(navController = navController, startDestination = Routes.ONBOARDING_WELCOME) {
|
||||
composable(Routes.ONBOARDING_WELCOME) {
|
||||
OnboardingWelcomeScreen(
|
||||
onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) },
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_PICKER) {
|
||||
val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) }
|
||||
AccountPickerScreen(
|
||||
onBack = {},
|
||||
onAccountAdded = { id ->
|
||||
onboarding.onAccountAdded(id)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER)
|
||||
},
|
||||
onPickProvider = { provider ->
|
||||
navController.navigate(Routes.onboardingAppPassword(provider.key))
|
||||
},
|
||||
onManualSetup = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ONBOARDING_APP_PASSWORD_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val key = entry.arguments?.getString(Routes.APP_PASSWORD_ARG_PROVIDER).orEmpty()
|
||||
val viewModel = remember {
|
||||
AppPasswordViewModel(
|
||||
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to key)),
|
||||
accountRepo,
|
||||
)
|
||||
}
|
||||
AppPasswordSetupScreen(
|
||||
onBack = {},
|
||||
onAccountAdded = { id ->
|
||||
onboarding.onAccountAdded(id)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER)
|
||||
}
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
AddAnotherAccountScreen(
|
||||
onAddAnother = {
|
||||
navController.navigate(Routes.ONBOARDING_PICKER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onFinish = {
|
||||
val id = onboarding.firstAddedAccountId
|
||||
val dest = if (id != null) Routes.mailboxForAccount(id) else Routes.MAILBOX
|
||||
navController.navigate(dest) {
|
||||
popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.MAILBOX_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
val account = entry.arguments?.getString(Routes.MAILBOX_ARG_ACCOUNT).orEmpty()
|
||||
val viewModel = remember {
|
||||
MailboxViewModel(
|
||||
mailRepo,
|
||||
accountRepo,
|
||||
FakeMailSyncer(),
|
||||
SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to account)),
|
||||
)
|
||||
}
|
||||
MailboxScreen(
|
||||
onOpenMessage = {},
|
||||
onCompose = {},
|
||||
onOpenDrafts = {},
|
||||
onOpenOutbox = {},
|
||||
onAddAccount = {},
|
||||
onOpenCompose = {},
|
||||
onSelectTab = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onboarding_addsAppPasswordAccount_thenLandsOnFirstAccountInbox() {
|
||||
val accountRepo = FakeAccountRepository()
|
||||
val mailRepo = FakeMailRepository(messages = listOf(inboxMessage()))
|
||||
setOnboardingContent(accountRepo, mailRepo)
|
||||
|
||||
// Welcome → picker.
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
|
||||
|
||||
// Picker → Gmail app-password setup.
|
||||
waitForText("Gmail")
|
||||
composeTestRule.onNodeWithText("Gmail").performClick()
|
||||
|
||||
// App-password setup: email + app password come from the user; servers come from the preset.
|
||||
// performScrollTo first — on the short default matrix emulator the fields and the "Test and
|
||||
// add" button sit below the fold of this scrolling screen, and a positional click on an
|
||||
// off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD).
|
||||
waitForText(string(R.string.app_password_email))
|
||||
// Gmail requires 2-Step Verification before app passwords, so its screen links Google's
|
||||
// setup article (issue #98). iCloud gets the same kind of link, in Apple's own terminology
|
||||
// (see icloudSetup_hasTwoFactorHelpLink); Yahoo does not (see
|
||||
// yahooSetup_hasNoTwoFactorHelpLink).
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help))
|
||||
.performScrollTo().assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_email))
|
||||
.performScrollTo().performTextInput("e2e@gmail.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_field))
|
||||
.performScrollTo().performTextInput("app-pass")
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add))
|
||||
.performScrollTo().performClick()
|
||||
|
||||
// "Add another?" prompt → No.
|
||||
waitForText(string(R.string.onboarding_add_another_prompt))
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// Landed on the first (and only) account's inbox.
|
||||
waitForText("E2E first message")
|
||||
composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun yahooSetup_hasNoTwoFactorHelpLink() {
|
||||
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
|
||||
waitForText("Yahoo Mail")
|
||||
composeTestRule.onNodeWithText("Yahoo Mail").performClick()
|
||||
|
||||
// Yahoo's setup screen keeps its app-password link (now pointing at Yahoo's step-by-step
|
||||
// instructions article instead of the generic account-security page, issue #155)…
|
||||
waitForText(string(R.string.app_password_open_page, "Yahoo Mail"))
|
||||
// …but gains no two-factor help link: unlike Gmail and iCloud, Yahoo gates nothing on it
|
||||
// (issue #98, #153, #155).
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun icloudSetup_hasTwoFactorHelpLink() {
|
||||
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
|
||||
waitForText("iCloud Mail")
|
||||
composeTestRule.onNodeWithText("iCloud Mail").performClick()
|
||||
|
||||
// Apple also won't issue an app-specific password until two-factor authentication is on,
|
||||
// so iCloud's screen links Apple's own setup article too — using Apple's terminology for
|
||||
// the button ("Two-Factor Authentication"), not Google's "2-Step Verification" (issue #153).
|
||||
waitForText(string(R.string.app_password_2fa_help_icloud))
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud))
|
||||
.performScrollTo().assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, "iCloud Mail"))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appPasswordSetup_showsAccountPasswordDisclaimerNearField() {
|
||||
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
|
||||
waitForText("Gmail")
|
||||
composeTestRule.onNodeWithText("Gmail").performClick()
|
||||
|
||||
// Issue #160: new users unfamiliar with app passwords commonly try their regular account
|
||||
// password first. The disclaimer must render as supporting text directly under the "App
|
||||
// password" field itself (not only in the intro InfoCards above), on every preset provider
|
||||
// screen since they all share this composable.
|
||||
waitForText(string(R.string.app_password_field))
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_field_disclaimer))
|
||||
.performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reader
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/** Compose UI tests for the reader's attachment list: the downloaded indicator and the accordion. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReaderScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun seeMore(extraCount: Int) =
|
||||
composeTestRule.activity.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount)
|
||||
|
||||
private val messageId = "imap:a:INBOX:1"
|
||||
private val message = Message(
|
||||
id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
|
||||
subject = "Subject", snippet = "", body = "Hello body", isHtml = false, timestampMillis = 1_000L,
|
||||
isRead = true, isStarred = false,
|
||||
)
|
||||
|
||||
private fun attachment(partIndex: Int, filename: String) =
|
||||
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
|
||||
|
||||
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
|
||||
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message),
|
||||
attachments = attachments,
|
||||
downloadedParts = downloadedParts,
|
||||
)
|
||||
val viewModel = ReaderViewModel(
|
||||
SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
|
||||
repo,
|
||||
SettingsRepository(context),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_showsDownloadedIndicator_forCachedAttachment() {
|
||||
renderReader(listOf(attachment(0, "report.pdf")), downloadedParts = setOf(0))
|
||||
|
||||
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_singleAttachment_showsNoAccordion() {
|
||||
renderReader(listOf(attachment(0, "solo.pdf")))
|
||||
|
||||
composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed()
|
||||
// A lone attachment has no "See more" control.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_multipleAttachments_collapseExtrasUntilExpanded() {
|
||||
renderReader(listOf(attachment(0, "one.pdf"), attachment(1, "two.pdf"), attachment(2, "three.pdf")))
|
||||
|
||||
// First row shown; the two extras are hidden behind the collapsed accordion.
|
||||
composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("two.pdf").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist()
|
||||
|
||||
// Tapping the control reveals the remaining rows.
|
||||
composeTestRule.onNodeWithText(seeMore(2)).performClick()
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("two.pdf").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText("two.pdf").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_twoAttachments_useSingularPlural() {
|
||||
renderReader(listOf(attachment(0, "a.pdf"), attachment(1, "b.pdf")))
|
||||
|
||||
// Exactly one extra: the singular plural form, e.g. "See 1 more attachment".
|
||||
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* End-to-end test for the per-account settings screen: editing the signature and toggling the
|
||||
* per-account notification switch must round-trip through the real Room-backed repository.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountSettingsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val account = Account(
|
||||
id = "imap:me@example.com",
|
||||
email = "me@example.com",
|
||||
displayName = "Me",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private var manageSignaturesClicked = false
|
||||
|
||||
private fun setContent(): AccountSettingsRepository {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
|
||||
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
|
||||
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
|
||||
// the test process.
|
||||
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
val repository = AccountSettingsRepository(db.accountSettingsDao())
|
||||
runBlocking {
|
||||
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
|
||||
repository.ensureDefaults(account.id)
|
||||
}
|
||||
val viewModel = AccountSettingsViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)),
|
||||
accountRepository = FakeAccountRepository(accounts = listOf(account)),
|
||||
accountSettingsRepository = repository,
|
||||
signatureRepository = SignatureRepository(db.signatureDao()),
|
||||
syncScheduler = SyncScheduler(Provider { WorkManager.getInstance(context) }),
|
||||
settingsRepository = SettingsRepository(context),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AccountSettingsScreen(
|
||||
onBack = {},
|
||||
onManageSignatures = { manageSignaturesClicked = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manageSignatures_opensTheSignaturesScreen() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { manageSignaturesClicked }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglingNotifications_persistsThroughTheRepository() {
|
||||
val repository = setContent() // ensureDefaults starts notifications enabled
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_account_new_mail)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { !repository.get(account.id).notificationsEnabled }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.contacts.ContactPermissionState
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI tests for the Settings contacts-autocomplete row (#129). The row is presentational, so each of
|
||||
* its three states — on / off / blocked-in-settings — is driven directly and asserted deterministically,
|
||||
* independent of the process's real `READ_CONTACTS` grant.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ContactAutocompleteRowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(state: ContactPermissionState, onClick: () -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ContactAutocompleteRow(state = state, onClick = onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun granted_showsOnSubtitle_andIsClickable() {
|
||||
var clicked = false
|
||||
setContent(ContactPermissionState.GRANTED) { clicked = true }
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).performClick()
|
||||
assertTrue("Tapping the row must invoke onClick", clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun denied_showsOffSubtitle() {
|
||||
setContent(ContactPermissionState.DENIED)
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blocked_showsBlockedSubtitle() {
|
||||
setContent(ContactPermissionState.BLOCKED)
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.contacts.ContactsPermissionManager
|
||||
import org.libremail.data.security.AppLockManager
|
||||
import org.libremail.data.security.DatabaseKeyCipher
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.KeystoreCrypto
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.push.BatteryOptimizationManager
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* End-to-end tests for the global settings screen. Tapping a policy must round-trip through the real
|
||||
* [SettingsRepository] (DataStore), proving the UI → ViewModel → persistence wiring; and enabling
|
||||
* app-lock on a device with no secure lock must surface the rejection message via a snackbar — now
|
||||
* visible to Compose semantics, unlike the former Toast it replaced.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SettingsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
|
||||
// Device reports no secure lock, so enabling app-lock is rejected with a message.
|
||||
private val insecureDevice = object : AppLockManager {
|
||||
override fun isDeviceSecure() = false
|
||||
}
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun settingsViewModel(settingsRepository: SettingsRepository): SettingsViewModel {
|
||||
val keyStore = DatabaseKeyStore(context, KeystoreCrypto(), DatabaseKeyCipher(), PassphraseSession())
|
||||
return SettingsViewModel(
|
||||
FakeAccountRepository(),
|
||||
settingsRepository,
|
||||
insecureDevice,
|
||||
keyStore,
|
||||
BatteryOptimizationManager(context),
|
||||
ContactsPermissionManager(context),
|
||||
SyncScheduler(Provider { WorkManager.getInstance(context) }),
|
||||
)
|
||||
}
|
||||
|
||||
private fun setContent(viewModel: SettingsViewModel) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
SettingsScreen(
|
||||
onAddAccount = {},
|
||||
onOpenAccount = {},
|
||||
onSelectTab = {},
|
||||
onReportProblem = {},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingFetchPolicy_persistsThroughTheRepository() {
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state
|
||||
setContent(settingsViewModel(settingsRepository))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.fetch_on_demand)).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { settingsRepository.fetchPolicy() } == FetchPolicy.ON_DEMAND
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactsAutocompleteRow_isShown() {
|
||||
// The contacts entry (#129) is wired into the real screen; it reflects the live permission
|
||||
// state, so we assert only that the row is present (state-specific rendering is covered by
|
||||
// ContactAutocompleteRowTest).
|
||||
setContent(settingsViewModel(SettingsRepository(context)))
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete))
|
||||
.performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enablingAppLockWithoutSecureDevice_showsRejectionSnackbar() {
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
runBlocking { settingsRepository.setAppLock(false) } // known starting state: off
|
||||
setContent(settingsViewModel(settingsRepository))
|
||||
|
||||
// App-lock lives under the collapsed "Advanced" section: expand it, then toggle the switch on.
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performScrollTo().performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.settings_adv_app_lock)).performScrollTo().performClick()
|
||||
|
||||
val message = string(R.string.app_lock_needs_device_lock)
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(message).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,25 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- Android Backup is opt-in and OFF by default (issue #21). allowBackup can't be toggled at
|
||||
runtime, so LibreMailBackupAgent gates it on the "Include settings in Android Backup"
|
||||
preference: with backup disabled the agent ships nothing. When enabled, only the settings
|
||||
DataStore is backed up per the allowlist in data_extraction_rules (API 31+) /
|
||||
backup_rules (API 29-30) — never credentials, the mail cache, or the Keystore-sealed cache
|
||||
passphrase. fullBackupOnly keeps this to Auto Backup (full-data) only. -->
|
||||
<application
|
||||
android:name=".LibreMailApplication"
|
||||
android:allowBackup="false"
|
||||
android:allowBackup="true"
|
||||
android:backupAgent=".backup.LibreMailBackupAgent"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -28,6 +38,38 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle mailto: links tapped in browsers and other apps (ACTION_VIEW), which is also
|
||||
what makes LibreMail appear on the system "Open by default" / default-apps screen
|
||||
where the platform exposes an email association. Android has no public RoleManager
|
||||
email role, so becoming the system default is OEM-dependent — hence "where supported". -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mailto" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- "Send email to <address>" targets from other apps (contacts, dialer, etc.). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mailto" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share-to-email: text and RFC-822 email payloads with recipients/subject/body extras. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="message/rfc822" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
|
||||
@@ -49,6 +91,19 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Separate-process trampoline for the app-lock key-invalidation recovery restart. Runs in
|
||||
its own ":restart" process (android:process) so it survives the main process being killed
|
||||
and can reliably relaunch the app from the outside — a same-process "startActivity then
|
||||
exit(0)" restart races ActivityManager and can be dropped (the ProcessPhoenix pattern).
|
||||
Not exported; only ProcessRestarter starts it. Translucent + excluded from recents so it
|
||||
never flashes UI or lingers in the switcher before it finishes and exits its own process. -->
|
||||
<activity
|
||||
android:name=".restart.RestartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":restart"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -60,20 +115,20 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
|
||||
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
|
||||
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
|
||||
(mobile/desktop) redirect in the Azure app registration. -->
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth's bundled manifest
|
||||
already declares RedirectUriReceiverActivity with an intent-filter for
|
||||
${appAuthRedirectScheme}, which build.gradle.kts sets to the Outlook scheme
|
||||
(org.libremail.outlook); the redirect URI org.libremail.outlook://oauth2redirect must
|
||||
be registered as a public-client (mobile/desktop) redirect in the Azure app
|
||||
registration. We only merge a theme onto that activity here: AppAuth declares no theme
|
||||
and RedirectUriReceiverActivity extends AppCompatActivity, so without an AppCompat theme
|
||||
it inherits the app's Theme.Material shell and crashes ("You need to use a Theme.AppCompat
|
||||
theme") when the redirect launches it. We reuse the translucent theme AppAuth itself
|
||||
applies to AuthorizationManagementActivity. -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
tools:node="merge">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="org.libremail.outlook" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -4,8 +4,8 @@ package org.libremail
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -17,9 +17,17 @@ import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.push.IdlePushManager
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.CrashReporter
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import org.libremail.restart.ProcessRestarter
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class LibreMailApplication : Application(), Configuration.Provider {
|
||||
class LibreMailApplication :
|
||||
Application(),
|
||||
Configuration.Provider {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@@ -27,10 +35,19 @@ class LibreMailApplication : Application(), Configuration.Provider {
|
||||
|
||||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
@Inject lateinit var accountRepository: AccountRepository
|
||||
// Lazy: resolving AccountRepository constructs the Room database, which — with app-lock + encrypted
|
||||
// cache on — blocks until the user authenticates. Keeping it lazy means the DB is built off the main
|
||||
// thread inside the collector below (never during onCreate), so the app never deadlocks at launch.
|
||||
@Inject lateinit var accountRepository: Lazy<AccountRepository>
|
||||
|
||||
@Inject lateinit var idlePushManager: IdlePushManager
|
||||
|
||||
@Inject lateinit var ringLogBuffer: RingLogBuffer
|
||||
|
||||
@Inject lateinit var crashReporter: CrashReporter
|
||||
|
||||
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
|
||||
@@ -44,14 +61,31 @@ class LibreMailApplication : Application(), Configuration.Provider {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Android also instantiates this Application in the separate ":restart" trampoline process
|
||||
// (see ProcessRestarter / RestartActivity), which exists only to relaunch the app from outside
|
||||
// a dying main process and is torn down within milliseconds. It must NOT run any of the app's
|
||||
// normal startup work — crash reporting, WorkManager scheduling, IDLE push all belong to the
|
||||
// main process. The main process (no ":restart" suffix) is unaffected, so normal launch is too.
|
||||
if (isRestartTrampolineProcess()) return
|
||||
// Wire up debug reporting first so crashes during the rest of startup are still captured.
|
||||
AppLog.install(ringLogBuffer)
|
||||
crashReporter.install()
|
||||
AppLog.i(TAG, "Application created")
|
||||
// Warm the settings cache so a later crash report can include non-PII settings without
|
||||
// touching DataStore on the crashing thread.
|
||||
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
|
||||
syncScheduler.schedulePeriodicSync()
|
||||
// Full-history backfill (#12) and device-only retention pruning (#13) run as their own bounded,
|
||||
// resumable background jobs so they never block foreground sync / pull-to-refresh.
|
||||
syncScheduler.schedulePeriodicBackfill()
|
||||
syncScheduler.schedulePeriodicPrune()
|
||||
// Run the IMAP IDLE push service only while it has something to do: the push setting is on
|
||||
// AND at least one account exists. This starts it when the first account is added and stops
|
||||
// it when the last is removed, reactively.
|
||||
appScope.launch {
|
||||
combine(
|
||||
settingsRepository.settings.map { it.pushIdle },
|
||||
accountRepository.observeAccounts().map { it.isNotEmpty() },
|
||||
accountRepository.get().observeAccounts().map { it.isNotEmpty() },
|
||||
) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts }
|
||||
.distinctUntilChanged()
|
||||
.collect { active ->
|
||||
@@ -70,4 +104,12 @@ class LibreMailApplication : Application(), Configuration.Provider {
|
||||
fun ensurePushStarted() {
|
||||
if (pushShouldBeActive) idlePushManager.start()
|
||||
}
|
||||
|
||||
/** True when this Application instance is the one Android spun up in the ":restart" aux process. */
|
||||
private fun isRestartTrampolineProcess(): Boolean =
|
||||
Application.getProcessName() == packageName + ProcessRestarter.PROCESS_SUFFIX
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LibreMail"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import android.view.WindowManager
|
||||
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.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.notifications.NotificationIntents
|
||||
import org.libremail.ui.LibreMailApp
|
||||
import org.libremail.ui.compose.ComposePrefill
|
||||
import org.libremail.ui.compose.IntentComposeParser
|
||||
import org.libremail.ui.lock.AppLockGateHost
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
// FragmentActivity (not ComponentActivity) because BiometricPrompt requires one for the app-lock
|
||||
// flow. FragmentActivity extends androidx.activity.ComponentActivity, so setContent / enableEdgeToEdge
|
||||
// and Hilt injection keep working unchanged.
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
/**
|
||||
* A pending compose request parsed from a `mailto:` / share intent, consumed once by the NavHost.
|
||||
* Held as Compose state so [onNewIntent] can re-trigger it while the activity is alive.
|
||||
*/
|
||||
private val pendingCompose = mutableStateOf<ComposePrefill?>(null)
|
||||
|
||||
/**
|
||||
* The message a tapped new-mail notification asks to open, consumed once by the NavHost. Compose
|
||||
* state for the same reason as [pendingCompose].
|
||||
*/
|
||||
private val pendingOpenMessageId = mutableStateOf<String?>(null)
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Foreground: recover IDLE push if a background start was previously blocked.
|
||||
@@ -36,24 +54,87 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
// Block screenshots and the recents-switcher snapshot while app-lock is on — the Compose gate
|
||||
// can't stop the system's task snapshot (captured around background). Gated on the setting
|
||||
// because FLAG_SECURE also blocks the user's own screenshots.
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.settings.map { it.appLock }.distinctUntilChanged().collect { secure ->
|
||||
if (secure) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
|
||||
LibreMailTheme(dynamicColor = dynamicColor) {
|
||||
NotificationPermissionEffect()
|
||||
LibreMailApp()
|
||||
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
|
||||
// the gate resolves straight to the content, so this is a no-op for most users.
|
||||
AppLockGateHost {
|
||||
LibreMailApp(
|
||||
pendingCompose = pendingCompose.value,
|
||||
onComposeHandled = { pendingCompose.value = null },
|
||||
pendingOpenMessageId = pendingOpenMessageId.value,
|
||||
onOpenMessageHandled = { pendingOpenMessageId.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
|
||||
@Composable
|
||||
private fun NotificationPermissionEffect() {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
LaunchedEffect(Unit) {
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses [intent] for a pending compose ([IntentComposeParser]) or open-message
|
||||
* ([NotificationIntents]) request — unless [IntentHandledMarker] says this exact intent instance
|
||||
* was already parsed.
|
||||
*
|
||||
* This used to be gated on `savedInstanceState == null` in [onCreate]: a non-null value was
|
||||
* assumed to mean "config-change recreation" — where Android redelivers the very same,
|
||||
* already-parsed intent and the NavHost restores its own compose/reader destination itself, so
|
||||
* re-parsing would only navigate to a duplicate. But Android *also* passes a restored, non-null
|
||||
* savedInstanceState when it recreates this activity after the process was killed in the
|
||||
* background and is then relaunched — e.g. by tapping a notification. There, `intent` is the new
|
||||
* tap, not a replay, but the old guard swallowed it exactly like a rotation: `pendingOpenMessageId`
|
||||
* was never set, so the tap silently landed wherever the restored back stack was, never the
|
||||
* message. That regression is #157.
|
||||
*
|
||||
* Marking the [Intent] instance itself — rather than branching on savedInstanceState, which can't
|
||||
* tell a config change and a process-death relaunch apart — is correct for both: a config-change
|
||||
* recreation redelivers the very same intent this activity already marked, so it's recognized and
|
||||
* skipped; a genuinely new intent — a fresh notification tap or mailto/share, whether delivered
|
||||
* warm via [onNewIntent] or cold via [onCreate] after a process-death relaunch — is never marked
|
||||
* yet, so it's always (re)parsed.
|
||||
*/
|
||||
private fun handleIntent(intent: Intent) {
|
||||
if (!IntentHandledMarker.markIfUnhandled(intent)) return
|
||||
IntentComposeParser.parse(intent)?.let { pendingCompose.value = it }
|
||||
NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an [Intent] as already parsed by [MainActivity.handleIntent], so a redelivery of the very same
|
||||
* instance — which is what Android does when it recreates an Activity for a configuration change — is
|
||||
* recognized and skipped instead of re-triggering a duplicate navigation. A freshly constructed intent
|
||||
* (a new notification tap or mailto/share, however it arrives) is never marked yet, so it is always
|
||||
* treated as unhandled — including right after a process-death relaunch, where `savedInstanceState` is
|
||||
* restored (non-null) but the intent itself is new. Internal (not private) so androidTest can verify
|
||||
* the marking contract directly. See #157.
|
||||
*/
|
||||
internal object IntentHandledMarker {
|
||||
private const val EXTRA_HANDLED = "org.libremail.extra.INTENT_HANDLED"
|
||||
|
||||
/** Marks [intent] handled and returns `true` — but only the first time this instance is seen. */
|
||||
fun markIfUnhandled(intent: Intent): Boolean {
|
||||
if (intent.getBooleanExtra(EXTRA_HANDLED, false)) return false
|
||||
intent.putExtra(EXTRA_HANDLED, true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationRequest
|
||||
import net.openid.appauth.AuthorizationResponse
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.openid.appauth.AuthorizationServiceConfiguration
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import net.openid.appauth.TokenResponse
|
||||
import org.json.JSONObject
|
||||
import org.libremail.BuildConfig
|
||||
|
||||
/**
|
||||
* Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted
|
||||
* `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2.
|
||||
*/
|
||||
@Singleton
|
||||
class GmailAuthManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val serviceConfig = AuthorizationServiceConfiguration(
|
||||
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
|
||||
Uri.parse("https://oauth2.googleapis.com/token"),
|
||||
)
|
||||
|
||||
/** False until a Google OAuth client id is provided in secrets.properties (see README). */
|
||||
val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank()
|
||||
|
||||
fun createAuthIntent(): Intent {
|
||||
val request = AuthorizationRequest.Builder(
|
||||
serviceConfig,
|
||||
BuildConfig.GMAIL_OAUTH_CLIENT_ID,
|
||||
ResponseTypeValues.CODE,
|
||||
Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI),
|
||||
)
|
||||
.setScope("openid email profile https://mail.google.com/")
|
||||
.build()
|
||||
return AuthorizationService(context).getAuthorizationRequestIntent(request)
|
||||
}
|
||||
|
||||
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
|
||||
val response = AuthorizationResponse.fromIntent(responseIntent)
|
||||
val exception = AuthorizationException.fromIntent(responseIntent)
|
||||
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
|
||||
|
||||
val service = AuthorizationService(context)
|
||||
try {
|
||||
val tokenResponse = suspendCancellableCoroutine { continuation ->
|
||||
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
|
||||
val email = emailFromIdToken(tokenResponse.idToken)
|
||||
?: throw IllegalStateException("Could not read the account email from the token")
|
||||
return OAuthResult(
|
||||
email = email,
|
||||
accessToken = tokenResponse.accessToken.orEmpty(),
|
||||
authStateJson = authState.jsonSerializeString(),
|
||||
)
|
||||
} finally {
|
||||
service.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */
|
||||
suspend fun freshAccessToken(authStateJson: String): FreshToken {
|
||||
val authState = AuthState.jsonDeserialize(authStateJson)
|
||||
val service = AuthorizationService(context)
|
||||
try {
|
||||
val accessToken = suspendCancellableCoroutine { continuation ->
|
||||
authState.performActionWithFreshTokens(service) { token, _, error ->
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return FreshToken(
|
||||
accessToken = accessToken,
|
||||
authStateJson = authState.jsonSerializeString(),
|
||||
accessTokenExpiry = authState.accessTokenExpirationTime,
|
||||
)
|
||||
} finally {
|
||||
service.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emailFromIdToken(idToken: String?): String? {
|
||||
if (idToken.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
val payload = idToken.split(".").getOrNull(1) ?: return null
|
||||
val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
|
||||
JSONObject(json).optString("email").ifBlank { null }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
@@ -22,6 +18,10 @@ import net.openid.appauth.ResponseTypeValues
|
||||
import net.openid.appauth.TokenRequest
|
||||
import org.json.JSONObject
|
||||
import org.libremail.BuildConfig
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret.
|
||||
@@ -34,9 +34,7 @@ import org.libremail.BuildConfig
|
||||
* both personal Microsoft accounts and work/school (Microsoft 365).
|
||||
*/
|
||||
@Singleton
|
||||
class OutlookAuthManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
class OutlookAuthManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
private val serviceConfig = AuthorizationServiceConfiguration(
|
||||
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
|
||||
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"),
|
||||
@@ -45,6 +43,12 @@ class OutlookAuthManager @Inject constructor(
|
||||
/** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */
|
||||
val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank()
|
||||
|
||||
/**
|
||||
* Builds the browser/Custom-Tab intent that starts the Microsoft sign-in.
|
||||
*
|
||||
* Throws [android.content.ActivityNotFoundException] when no usable browser is installed;
|
||||
* callers must guard the launch and surface that as an error rather than crashing.
|
||||
*/
|
||||
fun createAuthIntent(): Intent {
|
||||
val request = AuthorizationRequest.Builder(
|
||||
serviceConfig,
|
||||
@@ -55,18 +59,43 @@ class OutlookAuthManager @Inject constructor(
|
||||
// One consent covering both resources; per-resource access tokens are minted later.
|
||||
.setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE")
|
||||
.build()
|
||||
return AuthorizationService(context).getAuthorizationRequestIntent(request)
|
||||
// The returned intent is self-contained, so dispose the service (and its Custom-Tabs
|
||||
// warmup binding) immediately instead of leaking one per button tap.
|
||||
val service = AuthorizationService(context)
|
||||
return try {
|
||||
service.getAuthorizationRequestIntent(request)
|
||||
} finally {
|
||||
service.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
|
||||
val response = AuthorizationResponse.fromIntent(responseIntent)
|
||||
val exception = AuthorizationException.fromIntent(responseIntent)
|
||||
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
|
||||
val authCode = response.authorizationCode
|
||||
?: throw exception ?: IllegalStateException("No authorization code was returned")
|
||||
|
||||
// Microsoft issues one access token per resource, so the authorization-code exchange must
|
||||
// name a single resource. AppAuth's createTokenExchangeRequest() sends no scope, which makes
|
||||
// Microsoft reject our multi-resource consent code with AADSTS70011 ("must include a 'scope'
|
||||
// input parameter"). Request the OIDC scopes plus the Exchange Online resource so we still get
|
||||
// an id_token (for the email) and a refresh token to mint the Graph token from later.
|
||||
// This mirrors every field createTokenExchangeRequest() sets — including the nonce, which
|
||||
// AppAuth checks against the id_token's nonce claim (omitting it fails id_token validation).
|
||||
val exchangeRequest = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID)
|
||||
.setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
|
||||
.setAuthorizationCode(authCode)
|
||||
.setRedirectUri(Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI))
|
||||
.setCodeVerifier(response.request.codeVerifier)
|
||||
.setNonce(response.request.nonce)
|
||||
.setScope("openid email $OFFLINE $OUTLOOK_SCOPE")
|
||||
.build()
|
||||
|
||||
val service = AuthorizationService(context)
|
||||
try {
|
||||
val tokenResponse = suspendCancellableCoroutine { continuation ->
|
||||
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
|
||||
service.performTokenRequest(exchangeRequest) { token, error ->
|
||||
if (token != null) {
|
||||
continuation.resume(token)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.settings.AppSettings
|
||||
|
||||
/**
|
||||
* Single source of truth for what LibreMail is willing to hand to Android Backup. Kept in lockstep
|
||||
* with `res/xml/data_extraction_rules.xml` (API 31+) and `res/xml/backup_rules.xml` (API 29-30); the
|
||||
* path constants here are asserted against those resources by `DataExtractionRulesTest`.
|
||||
*
|
||||
* Only re-creatable user preferences are eligible. The mail cache re-downloads on the next sync, and
|
||||
* the credentials plus the Keystore-sealed cache passphrase are device-bound secrets that would only
|
||||
* ever restore as undecryptable ciphertext — so they are never backed up.
|
||||
*/
|
||||
object BackupPolicy {
|
||||
|
||||
/** `filesDir`-relative DataStore file holding user preferences — the only data we back up. */
|
||||
const val SAFE_SETTINGS_FILE: String = "datastore/libremail_settings.preferences_pb"
|
||||
|
||||
/** `filesDir`-relative paths that must never leave the device. */
|
||||
val EXCLUDED_FILE_PATHS: List<String> = listOf(
|
||||
// Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is
|
||||
// non-exportable and device-bound, so this ciphertext is useless anywhere else.
|
||||
"datastore/libremail_dbkey.preferences_pb",
|
||||
)
|
||||
|
||||
/**
|
||||
* `databases`-dir-relative names that must never leave the device: the encrypted mail cache
|
||||
* ([DatabaseFiles.NAME]) AND the accounts + encrypted-credentials database
|
||||
* ([DatabaseFiles.ACCOUNTS_NAME]), each with its SQLite sidecars. Derived from [DatabaseFiles]
|
||||
* rather than hand-listed, so a newly added database can never silently fall out of the
|
||||
* never-back-up set (issue #103).
|
||||
*/
|
||||
val EXCLUDED_DATABASE_PATHS: List<String> =
|
||||
DatabaseFiles.fileNames(DatabaseFiles.NAME) +
|
||||
DatabaseFiles.fileNames(DatabaseFiles.ACCOUNTS_NAME)
|
||||
|
||||
/**
|
||||
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
|
||||
* (or transferred device-to-device) unless the user has explicitly enabled it in Settings.
|
||||
*/
|
||||
fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import android.app.backup.BackupAgentHelper
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.libremail.data.settings.settingsDataStore
|
||||
import org.libremail.data.settings.toAppSettings
|
||||
|
||||
/**
|
||||
* Enforces the runtime backup opt-in on top of Android Auto Backup.
|
||||
*
|
||||
* `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings
|
||||
* in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the
|
||||
* user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to
|
||||
* the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and
|
||||
* `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the
|
||||
* mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase.
|
||||
*
|
||||
* Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore
|
||||
* paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and
|
||||
* full-data restore uses the default `onRestoreFile` handling.
|
||||
*
|
||||
* The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend
|
||||
* on Hilt or `Application.onCreate` having run in the framework's restricted backup mode.
|
||||
*/
|
||||
class LibreMailBackupAgent : BackupAgentHelper() {
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
if (backupOptedIn()) {
|
||||
super.onFullBackup(data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */
|
||||
private fun backupOptedIn(): Boolean = runCatching {
|
||||
runBlocking {
|
||||
BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings())
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.contacts
|
||||
|
||||
/**
|
||||
* Where the optional contacts-autocomplete permission stands, as the Settings entry (#129) shows it.
|
||||
* - [GRANTED]: on — recipient autocomplete works.
|
||||
* - [DENIED]: off but re-requestable in-app (never asked, or denied once without "don't ask again").
|
||||
* - [BLOCKED]: off and no longer re-requestable — the only way back is the system settings screen.
|
||||
*/
|
||||
enum class ContactPermissionState { GRANTED, DENIED, BLOCKED }
|
||||
|
||||
/**
|
||||
* Pure mapping from the three Android permission signals to a [ContactPermissionState]. Kept free of
|
||||
* Android types so it is exhaustively unit-testable; the live inputs are read by
|
||||
* [ContactsPermissionManager] (grant), the Activity (`shouldShowRequestPermissionRationale`), and
|
||||
* [org.libremail.data.settings.SettingsRepository] (whether the system dialog has ever been shown).
|
||||
*/
|
||||
object ContactPermissionDecision {
|
||||
|
||||
/**
|
||||
* Resolve the current state:
|
||||
* - [granted]: `READ_CONTACTS` is held → [ContactPermissionState.GRANTED].
|
||||
* - [showRationale]: the OS says a rationale should precede a re-request, i.e. the user denied
|
||||
* once without "don't ask again" → still re-requestable, [ContactPermissionState.DENIED].
|
||||
* - [alreadyRequested]: the system dialog has been shown before. Combined with `!showRationale`
|
||||
* (and not granted) this is the permanently-denied case → [ContactPermissionState.BLOCKED].
|
||||
*
|
||||
* The remaining case — not granted, no rationale, never requested — is a fresh install that has
|
||||
* simply never asked, so an in-app request will still surface the dialog: [ContactPermissionState.DENIED].
|
||||
*/
|
||||
fun resolve(granted: Boolean, showRationale: Boolean, alreadyRequested: Boolean): ContactPermissionState = when {
|
||||
granted -> ContactPermissionState.GRANTED
|
||||
showRationale -> ContactPermissionState.DENIED
|
||||
alreadyRequested -> ContactPermissionState.BLOCKED
|
||||
else -> ContactPermissionState.DENIED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads this app's `READ_CONTACTS` grant and deep-links to the system screen where it can be changed.
|
||||
* `READ_CONTACTS` powers recipient autocomplete only (see [ContactsRepository]); the whole feature is
|
||||
* optional and degrades gracefully when the permission is absent.
|
||||
*
|
||||
* Deliberately Context-only so it can back both the onboarding opt-in step and the Settings entry.
|
||||
* The `shouldShowRequestPermissionRationale` signal needs an Activity, so it is read in the Compose
|
||||
* layer and combined with [ContactPermissionDecision]; this manager stays free of Activity state.
|
||||
*/
|
||||
@Singleton
|
||||
class ContactsPermissionManager @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
/** True when `READ_CONTACTS` is currently granted to this app. */
|
||||
fun hasPermission(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Intent to this app's system details screen, where **Permissions → Contacts** can be toggled.
|
||||
* Used to recover the permanently-denied ("Don't allow" / don't-ask-again) case, which can no
|
||||
* longer be re-requested in-app. Always resolvable since API 9.
|
||||
*/
|
||||
fun settingsIntent(): Intent = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
}
|
||||
@@ -4,22 +4,17 @@ package org.libremail.contacts
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract.CommonDataKinds.Email
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** A device contact match for recipient autocomplete. */
|
||||
data class ContactSuggestion(
|
||||
val name: String,
|
||||
val email: String,
|
||||
)
|
||||
data class ContactSuggestion(val name: String, val email: String)
|
||||
|
||||
/** Looks up device contacts (ContactsContract) for recipient autocomplete. */
|
||||
@Singleton
|
||||
class ContactsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
class ContactsRepository @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
/** Returns up to [LIMIT] contacts whose name or email matches [query]. Empty if no permission. */
|
||||
suspend fun search(query: String): List<ContactSuggestion> = withContext(Dispatchers.IO) {
|
||||
if (query.length < 2) return@withContext emptyList()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The per-message on-disk attachment cache directory, keyed by a filesystem-safe form of the message
|
||||
* id. Shared by the writer ([org.libremail.data.repository.MailRepositoryImpl]) and the retention
|
||||
* pruner ([org.libremail.data.sync.MailPruner]) so the two can never disagree on where a message's
|
||||
* attachments live — a divergence would silently leak orphaned files that the pruner no longer finds.
|
||||
*/
|
||||
internal fun attachmentCacheDir(cacheDir: File, messageId: String): File {
|
||||
val safeId = messageId.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
return File(cacheDir, "attachments/$safeId")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.mail.HtmlToText
|
||||
import org.libremail.mail.ReplyContext
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the
|
||||
* matching HTML (the quote rendered as a `<blockquote>`), so the reply can go out as
|
||||
* `multipart/alternative` without the user having to re-format the quote.
|
||||
*/
|
||||
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String)
|
||||
|
||||
/**
|
||||
* Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
|
||||
* reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
|
||||
*
|
||||
* HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then
|
||||
* quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup.
|
||||
* The plaintext quote's "> " / attribution structure is then rendered to a clean `<blockquote>` for
|
||||
* the HTML alternative.
|
||||
*/
|
||||
object ReplyBuilder {
|
||||
|
||||
fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
|
||||
ReplyMode.REPLY -> reply(context, cc = "")
|
||||
ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", "))
|
||||
ReplyMode.FORWARD -> {
|
||||
val body = forwardedBody(context)
|
||||
ReplyContent(
|
||||
to = "",
|
||||
cc = "",
|
||||
subject = prefixedSubject(context.subject, "Fwd:"),
|
||||
body = body,
|
||||
bodyHtml = htmlOf(body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reply(context: ReplyContext, cc: String): ReplyContent {
|
||||
val body = quotedReply(context)
|
||||
return ReplyContent(
|
||||
to = context.fromEmail,
|
||||
cc = cc,
|
||||
subject = prefixedSubject(context.subject, "Re:"),
|
||||
body = body,
|
||||
bodyHtml = htmlOf(body),
|
||||
)
|
||||
}
|
||||
|
||||
/** Everyone on the original To/Cc except ourselves and the original sender (who becomes the To). */
|
||||
private fun replyAllCc(context: ReplyContext, selfEmail: String): List<String> = (
|
||||
context.toRecipients +
|
||||
context.ccRecipients
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctBy { it.lowercase(Locale.ROOT) }
|
||||
.filterNot { it.equals(selfEmail, ignoreCase = true) || it.equals(context.fromEmail, ignoreCase = true) }
|
||||
|
||||
/** Adds [prefix] unless the subject already starts with it (case-insensitive), avoiding "Re: Re:". */
|
||||
private fun prefixedSubject(subject: String, prefix: String): String {
|
||||
val trimmed = subject.trim()
|
||||
return if (trimmed.startsWith(prefix, ignoreCase = true)) trimmed else "$prefix $trimmed"
|
||||
}
|
||||
|
||||
private fun quotedReply(context: ReplyContext): String {
|
||||
val original = bodyText(context).lineSequence().joinToString("\n") { "> $it" }
|
||||
return "\n\nOn ${formatDate(context.sentDateMillis)}, ${context.fromEmail} wrote:\n$original"
|
||||
}
|
||||
|
||||
private fun forwardedBody(context: ReplyContext): String = buildString {
|
||||
append("\n\n---------- Forwarded message ----------\n")
|
||||
append("From: ${context.fromEmail}\n")
|
||||
append("Date: ${formatDate(context.sentDateMillis)}\n")
|
||||
append("Subject: ${context.subject}\n")
|
||||
append("To: ${context.toRecipients.joinToString(", ")}\n\n")
|
||||
append(bodyText(context))
|
||||
}
|
||||
|
||||
/** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */
|
||||
private fun bodyText(context: ReplyContext): String =
|
||||
if (context.isHtml) HtmlToText.convert(context.body) else context.body
|
||||
|
||||
/** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */
|
||||
private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body))
|
||||
|
||||
private fun formatDate(millis: Long): String =
|
||||
SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.domain.model.Signature
|
||||
import org.libremail.richtext.RichTextContent
|
||||
import org.libremail.richtext.RichTextHtml
|
||||
|
||||
/**
|
||||
* A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback
|
||||
* and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text
|
||||
* model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a
|
||||
* From-account swap can strip the previously applied block from either representation.
|
||||
*
|
||||
* The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature.
|
||||
*/
|
||||
data class SignatureBlock(val plain: String, val html: String) {
|
||||
val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty()
|
||||
|
||||
companion object {
|
||||
val EMPTY = SignatureBlock("", "")
|
||||
|
||||
/** The block for [signature], or [EMPTY] when there is none / it is blank. */
|
||||
fun of(signature: Signature?): SignatureBlock {
|
||||
if (signature == null) return EMPTY
|
||||
val plainSig = signature.plainText().trimEnd()
|
||||
if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY
|
||||
return SignatureBlock(
|
||||
plain = "$DELIMITER_PLAIN$plainSig",
|
||||
html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DELIMITER_PLAIN = "\n\n-- \n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data
|
||||
|
||||
import org.libremail.mail.HtmlToText
|
||||
|
||||
/**
|
||||
* Derives the one-line mailbox preview snippet that is persisted alongside a fetched body.
|
||||
*
|
||||
* HTML bodies are reduced to readable text via [HtmlToText] (script/style *content* dropped, tags
|
||||
* stripped, entities decoded) before the whitespace collapsing. Plain-text bodies get no markup
|
||||
* handling at all — literal `<`/`>` characters survive — only whitespace collapsing. Both paths end
|
||||
* with the same [MAX_LENGTH] cap.
|
||||
*
|
||||
* Derivation runs once, when a body is fetched and cached (plus the one-off migration backfill) —
|
||||
* never per mailbox-list row.
|
||||
*/
|
||||
object Snippet {
|
||||
|
||||
const val MAX_LENGTH = 140
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
fun of(body: String, isHtml: Boolean): String {
|
||||
val text = if (isHtml) HtmlToText.convert(body) else body
|
||||
return text.replace(WHITESPACE, " ").trim().take(MAX_LENGTH)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.accountMigrationDataStore: DataStore<Preferences> by
|
||||
preferencesDataStore(name = "libremail_account_migration")
|
||||
|
||||
/**
|
||||
* One-time, crash-safe move of the account tables (`accounts`, `credentials`, `account_settings`,
|
||||
* `signatures`) out of the auth-bound cache database [LibreMailDatabase] into the non-auth
|
||||
* [AccountDatabase] (issue #111). Runs at startup, driven by `DatabaseModule.provideDatabase`, BEFORE
|
||||
* Room opens the cache and its [MIGRATION_15_16] drops the moved tables.
|
||||
*
|
||||
* ### Why not a Room migration
|
||||
* The copy is cross-database, so it needs `ATTACH DATABASE`, which SQLite forbids inside the
|
||||
* transaction Room wraps every migration in. It therefore runs here on a dedicated SQLCipher
|
||||
* connection before Room opens either database.
|
||||
*
|
||||
* ### Handling the encrypted source
|
||||
* When the opt-in encrypted cache is on, the source `libremail.db` is SQLCipher-encrypted. The
|
||||
* caller resolves and hands us its passphrase (the same one Room uses to open it); we attach the
|
||||
* cache with that passphrase and copy into a plaintext `libremail-accounts.db`. When the cache is
|
||||
* plaintext the passphrase is empty. Reading the source's schema validates the passphrase, so a
|
||||
* genuinely wrong key fails loudly here (the same open would fail in Room) rather than losing data.
|
||||
*
|
||||
* The unrecoverable-key case does not reach us: `provideDatabase` wipes an undecryptable cache (and
|
||||
* resets its seals) BEFORE calling us, so we then see a fresh/empty cache with nothing to move — the
|
||||
* accounts trapped in that already-invalidated cache are lost regardless (the pre-existing bug), but
|
||||
* no future invalidation can strand them again once they live in [AccountDatabase].
|
||||
*
|
||||
* ### Crash-safety & idempotency
|
||||
* - We never drop the source here; [MIGRATION_15_16] does that after we return, so if we crash the
|
||||
* source rows are still intact for the next attempt.
|
||||
* - The copy uses `INSERT OR IGNORE`, so a re-run after a mid-copy crash converges (existing rows
|
||||
* are skipped, never duplicated, and never overwrite anything the user changed post-migration).
|
||||
* - The "done" flag is only set after a successful copy; until then every start retries. Once set we
|
||||
* return immediately and never touch the cache passphrase again — so after migration the account
|
||||
* database opens with no Keystore dependency at all.
|
||||
*/
|
||||
@Singleton
|
||||
class AccountDataMigrator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val keyStore: DatabaseKeyStore,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Copy the account tables into [AccountDatabase] if it has not been done yet. Idempotent and
|
||||
* safe to call from every `provideDatabase` construction. Throws (rather than silently skipping)
|
||||
* on an unexpected copy failure so the caller does not proceed to drop the source tables — a
|
||||
* crash-loop that preserves data is strictly safer than a wipe that loses it.
|
||||
*/
|
||||
suspend fun migrateIfNeeded() {
|
||||
if (isDone()) return
|
||||
val cacheFile = context.getDatabasePath(DatabaseFiles.NAME)
|
||||
if (cacheFile.exists() && cacheFile.length() > 0L) {
|
||||
// Read the cache in its CURRENT on-disk form. `provideDatabase` runs us before it converts
|
||||
// between plaintext and encrypted, so the key is empty unless the file is encrypted now.
|
||||
val cacheKey = if (DatabaseEncryption.isEncrypted(cacheFile)) {
|
||||
keyStore.resolvePassphrase(settingsRepository.settings.first().appLock)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val accountsFile = context.getDatabasePath(DatabaseFiles.ACCOUNTS_NAME)
|
||||
withContext(Dispatchers.IO) { copyAccountTables(cacheFile, cacheKey, accountsFile) }
|
||||
}
|
||||
markDone()
|
||||
}
|
||||
|
||||
private suspend fun isDone(): Boolean = context.accountMigrationDataStore.data.first()[DONE] == true
|
||||
|
||||
private suspend fun markDone() {
|
||||
context.accountMigrationDataStore.edit { it[DONE] = true }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibreMailAcctMigrate"
|
||||
private val DONE = booleanPreferencesKey("accounts_moved_out_of_cache")
|
||||
|
||||
/** The account tables, parent before children so foreign keys never block an insert. */
|
||||
private val TABLES = listOf("accounts", "credentials", "account_settings", "signatures")
|
||||
|
||||
/**
|
||||
* DDL for the account tables in [AccountDatabase] v1, copied verbatim from the exported Room
|
||||
* schema (`schemas/org.libremail.data.local.AccountDatabase/1.json`). It MUST stay byte-for-byte
|
||||
* identical to what Room generates for those entities, or Room silently accepts a subtly wrong
|
||||
* schema (its identity check only compares the hash it writes, not the pre-existing tables).
|
||||
* `AccountDataMigratorTest.migratorDdlMatchesExportedAccountDatabaseSchema` guards it against the
|
||||
* exported schema; `internal` only so that test can read it.
|
||||
*/
|
||||
internal val CREATE_TABLE_SQL = mapOf(
|
||||
"accounts" to
|
||||
"CREATE TABLE IF NOT EXISTS `accounts` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, " +
|
||||
"`imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, " +
|
||||
"`smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"credentials" to
|
||||
"CREATE TABLE IF NOT EXISTS `credentials` (`accountId` TEXT NOT NULL, " +
|
||||
"`encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"account_settings" to
|
||||
"CREATE TABLE IF NOT EXISTS `account_settings` (`accountId` TEXT NOT NULL, " +
|
||||
"`signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, " +
|
||||
"`notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, " +
|
||||
"`retentionMonths` INTEGER, PRIMARY KEY(`accountId`), " +
|
||||
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"signatures" to
|
||||
"CREATE TABLE IF NOT EXISTS `signatures` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, " +
|
||||
"`name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
|
||||
internal const val SIGNATURES_INDEX_SQL =
|
||||
"CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)"
|
||||
|
||||
/**
|
||||
* Copies the account tables from [cacheFile] (opened with [cachePassphrase]; empty = plaintext)
|
||||
* into a plaintext [accountsFile], creating the destination schema first. Opens the destination
|
||||
* as `main` and attaches the (possibly encrypted) cache as `cache`, so a plaintext connection
|
||||
* can still read the encrypted source via SQLCipher's per-attach key. Visible for the migrator
|
||||
* test; call [migrateIfNeeded] in production.
|
||||
*/
|
||||
internal fun copyAccountTables(cacheFile: File, cachePassphrase: String, accountsFile: File) {
|
||||
DatabaseEncryption.ensureNativeLibraryLoaded()
|
||||
val db = SQLiteDatabase.openOrCreateDatabase(
|
||||
accountsFile.absolutePath,
|
||||
"".toByteArray(Charsets.US_ASCII), // destination is plaintext
|
||||
null,
|
||||
null,
|
||||
)
|
||||
try {
|
||||
// No WAL: keep the destination in rollback-journal mode (as DatabaseEncryption does)
|
||||
// so that after close there is no -wal/-shm holding uncommitted rows for Room to miss.
|
||||
db.rawExecSQL("PRAGMA journal_mode = DELETE;")
|
||||
val keyLiteral = cachePassphrase.replace("'", "''")
|
||||
val cachePath = cacheFile.absolutePath.replace("'", "''")
|
||||
db.rawExecSQL("ATTACH DATABASE '$cachePath' AS cache KEY '$keyLiteral';")
|
||||
try {
|
||||
val present = presentTables(db)
|
||||
if (present.isEmpty()) return // fresh cache or already dropped: nothing to move
|
||||
TABLES.forEach { db.rawExecSQL(CREATE_TABLE_SQL.getValue(it)) }
|
||||
db.rawExecSQL(SIGNATURES_INDEX_SQL)
|
||||
// Copy by explicit shared column names, never SELECT *: the on-disk cache may predate
|
||||
// columns the current schema added (e.g. account_settings gained retentionCount /
|
||||
// retentionMonths at v13), and a bare SELECT * would then supply fewer values than the
|
||||
// destination has columns and fail the whole migration. Listing the columns the source
|
||||
// actually has lets the destination's newer columns take their defaults (NULL). Parent
|
||||
// first so an enforced foreign key would still be satisfied; INSERT OR IGNORE is idempotent.
|
||||
TABLES.filter { it in present }.forEach { table ->
|
||||
val cols = sharedColumns(db, table)
|
||||
db.rawExecSQL("INSERT OR IGNORE INTO `$table` ($cols) SELECT $cols FROM cache.`$table`")
|
||||
}
|
||||
Log.d(TAG, "moved account tables into the account database: $present")
|
||||
} finally {
|
||||
db.rawExecSQL("DETACH DATABASE cache;")
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
// Room opens the destination next; drop any sidecars the copy left so a stale WAL/SHM can't
|
||||
// confuse its first open.
|
||||
val dir = accountsFile.parentFile
|
||||
if (dir != null) {
|
||||
listOf("-wal", "-shm", "-journal").forEach { File(dir, accountsFile.name + it).delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentTables(db: SQLiteDatabase): Set<String> {
|
||||
val names = TABLES.joinToString(",") { "'$it'" }
|
||||
val present = mutableSetOf<String>()
|
||||
db.rawQuery(
|
||||
"SELECT name FROM cache.sqlite_master WHERE type = 'table' AND name IN ($names)",
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) present += cursor.getString(0)
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
/**
|
||||
* Column names present in BOTH the freshly-created destination `$table` (always the current
|
||||
* schema) and the source `cache.$table` (possibly an older on-disk schema), backtick-quoted and
|
||||
* comma-joined for an INSERT/SELECT column list. Destination-only columns are omitted so they
|
||||
* take their defaults instead of overflowing the value list.
|
||||
*/
|
||||
private fun sharedColumns(db: SQLiteDatabase, table: String): String {
|
||||
val source = tableColumns(db, "cache", table)
|
||||
return tableColumns(db, "main", table)
|
||||
.filter { it in source }
|
||||
.joinToString(", ") { "`$it`" }
|
||||
}
|
||||
|
||||
/** The column names of `$schema.$table`, in declared order, via `PRAGMA table_info`. */
|
||||
private fun tableColumns(db: SQLiteDatabase, schema: String, table: String): List<String> {
|
||||
val columns = mutableListOf<String>()
|
||||
db.rawQuery("PRAGMA $schema.table_info(`$table`)", null).use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndexOrThrow("name")
|
||||
while (cursor.moveToNext()) columns += cursor.getString(nameIndex)
|
||||
}
|
||||
return columns
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AccountSettingsDao
|
||||
import org.libremail.data.local.dao.CredentialDao
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
/**
|
||||
* Durable store for the pieces of an account that must survive a mail-cache wipe (issue #111): the
|
||||
* account itself, its sealed credential, per-account settings, and saved signatures.
|
||||
*
|
||||
* This lives in its OWN database file ([DatabaseFiles.ACCOUNTS_NAME]) that is deliberately NEVER
|
||||
* bound to the auth-bound SQLCipher key. When app-lock + encrypted-cache are on and that key is
|
||||
* invalidated (a genuine biometric re-enrollment or lock removal/re-add), only the mail cache
|
||||
* ([LibreMailDatabase]) becomes undecryptable and is wiped; this database is untouched, so the user
|
||||
* stays signed in instead of being dropped back into onboarding.
|
||||
*
|
||||
* It is plaintext on disk. The only secret it holds is [CredentialEntity.encryptedSecret], which is
|
||||
* already AES-GCM ciphertext sealed at the column level by the non-auth
|
||||
* [org.libremail.data.security.KeystoreCrypto] master key (and that key survives an auth-key
|
||||
* invalidation), so the secret never touches disk in the clear regardless of this file's own
|
||||
* encryption. Account metadata (email address, server hosts) is not a secret. Keeping the file
|
||||
* plaintext is what makes it maximally resilient — it can always be opened without any Keystore key,
|
||||
* so no key invalidation can ever strand it.
|
||||
*
|
||||
* Existing installs are migrated into this database once, at startup, by [AccountDataMigrator]
|
||||
* before [MIGRATION_15_16] drops the moved tables from the cache database.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
AccountEntity::class,
|
||||
CredentialEntity::class,
|
||||
AccountSettingsEntity::class,
|
||||
SignatureEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AccountDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
abstract fun credentialDao(): CredentialDao
|
||||
abstract fun accountSettingsDao(): AccountSettingsDao
|
||||
abstract fun signatureDao(): SignatureDao
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Converts the Room database file between plaintext and SQLCipher-encrypted form, in place and
|
||||
@@ -40,7 +40,7 @@ object DatabaseEncryption {
|
||||
* tables but not that pragma, and a reset version would make Room attempt a bogus migration.
|
||||
*/
|
||||
private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) {
|
||||
ensureLibraryLoaded()
|
||||
ensureNativeLibraryLoaded()
|
||||
val dir = dbFile.parentFile ?: error("database file has no parent directory")
|
||||
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
|
||||
|
||||
@@ -98,7 +98,13 @@ object DatabaseEncryption {
|
||||
}
|
||||
|
||||
@Volatile private var libraryLoaded = false
|
||||
private fun ensureLibraryLoaded() {
|
||||
|
||||
/**
|
||||
* Load SQLCipher's native library once. Public so other startup helpers that open a database via
|
||||
* [net.zetetic.database.sqlcipher.SQLiteDatabase] before Room does (e.g. [AccountDataMigrator])
|
||||
* can guarantee it is loaded first.
|
||||
*/
|
||||
fun ensureNativeLibraryLoaded() {
|
||||
if (libraryLoaded) return
|
||||
synchronized(this) {
|
||||
if (!libraryLoaded) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** Central names and wipe helper for the Room database files (and their SQLite sidecars). */
|
||||
object DatabaseFiles {
|
||||
|
||||
const val NAME = "libremail.db"
|
||||
|
||||
/**
|
||||
* The [org.libremail.data.local.AccountDatabase] file — accounts, credentials, per-account
|
||||
* settings and signatures. Deliberately a DIFFERENT file from [NAME] and NEVER wiped by [clear],
|
||||
* so a cache-key invalidation keeps the user signed in (issue #111).
|
||||
*/
|
||||
const val ACCOUNTS_NAME = "libremail-accounts.db"
|
||||
|
||||
/**
|
||||
* The statically-nameable SQLite sidecars that accompany a database file. A transient `-mj*`
|
||||
* master journal can also exist, but its suffix is random and so can't be listed by name —
|
||||
* [clear] leans on [Context.deleteDatabase] to sweep that one up.
|
||||
*/
|
||||
private val SIDECAR_SUFFIXES = listOf("-wal", "-shm", "-journal")
|
||||
|
||||
/**
|
||||
* [name] plus each of its statically-nameable sidecars. The single source of truth for which
|
||||
* on-disk files make up a database file; `BackupPolicy` derives its never-back-up set from this
|
||||
* so a new database (or a new sidecar suffix) can never silently fall out of the exclusions.
|
||||
*/
|
||||
fun fileNames(name: String): List<String> = listOf(name) + SIDECAR_SUFFIXES.map { name + it }
|
||||
|
||||
/**
|
||||
* Delete the cache database ([NAME]) and every sidecar — including the `-mj*` master journal a
|
||||
* hand-rolled suffix list would miss — via [Context.deleteDatabase]. NEVER touches
|
||||
* [ACCOUNTS_NAME], so a cache-key invalidation keeps the user signed in (issue #111). Call only
|
||||
* when no connection is open — used by the "clear + re-sync" path when the encryption key is
|
||||
* invalidated and the encrypted database can no longer be decrypted.
|
||||
*/
|
||||
fun clear(context: Context) {
|
||||
context.deleteDatabase(NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** How the cache database ([LibreMailDatabase]) must be opened, decided by [DatabaseProvisioner]. */
|
||||
sealed interface CacheOpenMode {
|
||||
/** Open with SQLCipher, keyed by [passphrase] — the opt-in encrypted cache. */
|
||||
data class Encrypted(val passphrase: String) : CacheOpenMode
|
||||
|
||||
/** Open with the default framework helper — the cache is plaintext on disk. */
|
||||
data object Plaintext : CacheOpenMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the one-time, blocking startup sequence that must complete BEFORE Room opens either database —
|
||||
* exactly once, memoized, and OFF the Hilt injection path (issue #93).
|
||||
*
|
||||
* `DatabaseModule.provideDatabase` used to do this work inline, with `runBlocking`, while Hilt
|
||||
* constructed the singleton [LibreMailDatabase]: a DataStore read, a Keystore op, a possible SQLCipher
|
||||
* re-key conversion, and (since #111) the cross-database [AccountDataMigrator]. All of it ran
|
||||
* synchronously on whichever thread first injected the database — which can be the main thread — so the
|
||||
* first DB access could jank or ANR (worst with the encrypted cache on). This class moves that work
|
||||
* behind [prepareCache]; the Hilt providers wire it into a [DeferredOpenHelperFactory] so it runs
|
||||
* lazily, on Room's background open, never at inject time.
|
||||
*
|
||||
* The sequence, its ordering, and its crash-safety are unchanged from the old `provideDatabase` — only
|
||||
* WHERE and WHEN it runs moved:
|
||||
* 1. If a screen-lock change flagged the encrypted cache for wiping, wipe it and reset its seals
|
||||
* (before Room opens the file, so no open connection is deleted underneath it).
|
||||
* 2. Run [AccountDataMigrator] — the one-time move of accounts/credentials/settings/signatures into
|
||||
* the non-auth [AccountDatabase] (issue #111). MUST precede opening the cache (whose
|
||||
* [MIGRATION_15_16] drops the moved tables) AND opening [AccountDatabase] (which reads the copied
|
||||
* rows). Both databases' open paths gate on [prepareCache], so the migrate-before-open guarantee
|
||||
* holds regardless of which database Room opens first.
|
||||
* 3. Resolve the encryption gate: convert the on-disk cache to the form the `encryptCache` setting
|
||||
* asks for, and report how the cache must be opened.
|
||||
*
|
||||
* [prepareCache] is memoized on success and guarded by a [Mutex], so the first database to open runs
|
||||
* the sequence and any concurrent or later opener awaits the same result. A failure is NOT memoized, so
|
||||
* it retries on the next open — preserving the migrator's "crash-loop rather than lose data" contract
|
||||
* (a throw here means the cache never opens, so [MIGRATION_15_16] never drops the not-yet-copied rows).
|
||||
*/
|
||||
@Singleton
|
||||
class DatabaseProvisioner internal constructor(
|
||||
private val context: Context,
|
||||
private val keyStore: DatabaseKeyStore,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val accountDataMigrator: AccountDataMigrator,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
keyStore: DatabaseKeyStore,
|
||||
settingsRepository: SettingsRepository,
|
||||
accountDataMigrator: AccountDataMigrator,
|
||||
) : this(context, keyStore, settingsRepository, accountDataMigrator, Dispatchers.IO)
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var prepared: CacheOpenMode? = null
|
||||
|
||||
/**
|
||||
* Runs the startup sequence exactly once (on [ioDispatcher]) and returns how the cache must be
|
||||
* opened. Idempotent and safe to call concurrently from both databases' open paths; the blocking
|
||||
* work runs on [ioDispatcher], never on the caller's thread past the suspension point.
|
||||
*/
|
||||
suspend fun prepareCache(): CacheOpenMode {
|
||||
prepared?.let { return it }
|
||||
return mutex.withLock {
|
||||
prepared ?: withContext(ioDispatcher) { runStartupSequence() }.also { prepared = it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runStartupSequence(): CacheOpenMode {
|
||||
val dbFile = context.getDatabasePath(DatabaseFiles.NAME)
|
||||
|
||||
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
|
||||
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
|
||||
// restarts the app; we wipe the cache HERE — before Room opens it — so the file is never
|
||||
// deleted from under an open connection. Crash-safe order: wipe + reset the seals, and only THEN
|
||||
// clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start. Only
|
||||
// libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file), so the
|
||||
// user stays signed in across the wipe (issue #111).
|
||||
if (keyStore.isClearPending()) {
|
||||
DatabaseFiles.clear(context)
|
||||
keyStore.resetSealedPassphrase()
|
||||
keyStore.clearClearPending()
|
||||
}
|
||||
|
||||
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
|
||||
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
|
||||
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
|
||||
// (nothing left to move) and we never block waiting on a passphrase we can't get.
|
||||
accountDataMigrator.migrateIfNeeded()
|
||||
|
||||
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
|
||||
// before the database is opened — so it never races an open connection; toggling the setting
|
||||
// therefore takes effect on the next app start. The passphrase source is resolved from which
|
||||
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
|
||||
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
|
||||
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
|
||||
// must never run on the main thread while the cache is locked (issue #93).
|
||||
val settings = settingsRepository.settings.first()
|
||||
val appLock = settings.appLock
|
||||
return when {
|
||||
settings.encryptCache -> {
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
CacheOpenMode.Encrypted(passphrase)
|
||||
}
|
||||
|
||||
DatabaseEncryption.isEncrypted(dbFile) -> {
|
||||
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
CacheOpenMode.Plaintext
|
||||
}
|
||||
|
||||
else -> CacheOpenMode.Plaintext
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
|
||||
/**
|
||||
* A [SupportSQLiteOpenHelper.Factory] that defers building the REAL open helper — and any blocking work
|
||||
* that choosing and creating it entails — from Room's build/inject path to the FIRST actual database
|
||||
* open (issue #93).
|
||||
*
|
||||
* Room calls [create] and [SupportSQLiteOpenHelper.setWriteAheadLoggingEnabled] while it builds the
|
||||
* database, on whichever thread injected it (possibly the main thread); neither may block. This factory
|
||||
* hands back a thin handle whose delegate is materialised only when the database is first opened
|
||||
* (`writableDatabase` / `readableDatabase`), which Room performs on its background query executor. The
|
||||
* [buildDelegate] lambda is where the caller runs the startup gate (see [DatabaseProvisioner]) and
|
||||
* picks the concrete factory — so all of that runs off the injection path and off the main thread.
|
||||
*/
|
||||
internal class DeferredOpenHelperFactory(
|
||||
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
|
||||
) : SupportSQLiteOpenHelper.Factory {
|
||||
override fun create(configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper =
|
||||
DeferredOpenHelper(configuration, buildDelegate)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lazy handle returned by [DeferredOpenHelperFactory]. Everything Room touches before the first
|
||||
* open is cheap; [buildDelegate] (which does the blocking work) runs only when [writableDatabase] or
|
||||
* [readableDatabase] is first read.
|
||||
*/
|
||||
private class DeferredOpenHelper(
|
||||
private val configuration: SupportSQLiteOpenHelper.Configuration,
|
||||
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
|
||||
) : SupportSQLiteOpenHelper {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/** Guarded by [lock]. Null until the database is first opened — `create()` must stay non-blocking. */
|
||||
private var delegate: SupportSQLiteOpenHelper? = null
|
||||
|
||||
/**
|
||||
* Guarded by [lock]. Room may set WAL before the first open; we remember the value and apply it when
|
||||
* the delegate is built, rather than building the delegate early (which would run the gate at inject
|
||||
* time). Null means "Room never asked", so the delegate keeps the real factory's own default.
|
||||
*/
|
||||
private var writeAheadLoggingEnabled: Boolean? = null
|
||||
|
||||
override val databaseName: String?
|
||||
get() = configuration.name
|
||||
|
||||
override fun setWriteAheadLoggingEnabled(enabled: Boolean) {
|
||||
synchronized(lock) {
|
||||
writeAheadLoggingEnabled = enabled
|
||||
delegate?.setWriteAheadLoggingEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
override val writableDatabase: SupportSQLiteDatabase
|
||||
get() = delegate().writableDatabase
|
||||
|
||||
override val readableDatabase: SupportSQLiteDatabase
|
||||
get() = delegate().readableDatabase
|
||||
|
||||
override fun close() {
|
||||
// Never opened means nothing to close; do NOT build the delegate just to close it.
|
||||
synchronized(lock) { delegate?.close() }
|
||||
}
|
||||
|
||||
private fun delegate(): SupportSQLiteOpenHelper = synchronized(lock) {
|
||||
delegate ?: buildDelegate(configuration).also { built ->
|
||||
writeAheadLoggingEnabled?.let(built::setWriteAheadLoggingEnabled)
|
||||
delegate = built
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,46 @@ package org.libremail.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
import org.libremail.data.local.dao.CredentialDao
|
||||
import org.libremail.data.local.dao.BackfillProgressDao
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
import org.libremail.data.local.entity.BackfillProgressEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
|
||||
/**
|
||||
* The offline mail cache. Everything here is re-derivable from the server on a fresh sync, so it is
|
||||
* the database that opt-in SQLCipher encryption is applied to and — when the auth-bound key is
|
||||
* invalidated — the one that "clear + re-sync" wipes.
|
||||
*
|
||||
* Account identity and user configuration (accounts, credentials, per-account settings, signatures)
|
||||
* are deliberately NOT here: they live in [AccountDatabase], a separate non-auth-bound file, so a
|
||||
* cache-key invalidation can never sign the user out (issue #111). [MIGRATION_15_16] dropped those
|
||||
* tables from this database; [AccountDataMigrator] copies existing rows into [AccountDatabase] first.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
AccountEntity::class,
|
||||
MessageEntity::class,
|
||||
CredentialEntity::class,
|
||||
AttachmentEntity::class,
|
||||
OutboxEntity::class,
|
||||
DraftEntity::class,
|
||||
FolderEntity::class,
|
||||
BackfillProgressEntity::class,
|
||||
],
|
||||
version = 7,
|
||||
version = 18,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class LibreMailDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun accountDao(): AccountDao
|
||||
abstract fun credentialDao(): CredentialDao
|
||||
abstract fun attachmentDao(): AttachmentDao
|
||||
abstract fun outboxDao(): OutboxDao
|
||||
abstract fun draftDao(): DraftDao
|
||||
abstract fun folderDao(): FolderDao
|
||||
abstract fun backfillProgressDao(): BackfillProgressDao
|
||||
}
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.FolderUnreadCount
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.OutboxMessage
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.Folder
|
||||
import org.libremail.domain.model.FolderRole
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.OutboxMessage
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.domain.model.SmtpParams
|
||||
import org.libremail.domain.model.UnreadCount
|
||||
import org.libremail.mail.AttachmentPart
|
||||
import org.libremail.mail.FetchedFolder
|
||||
import org.libremail.mail.FetchedMessage
|
||||
|
||||
internal fun AccountEntity.toDomain(): Account = Account(
|
||||
@@ -41,26 +50,39 @@ internal fun Account.toEntity(): AccountEntity = AccountEntity(
|
||||
smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name),
|
||||
)
|
||||
|
||||
internal fun AccountSettingsEntity.toDomain(): AccountSettings = AccountSettings(
|
||||
accountId = accountId,
|
||||
signature = signature,
|
||||
signatureEnabled = signatureEnabled,
|
||||
notificationsEnabled = notificationsEnabled,
|
||||
retentionCount = retentionCount,
|
||||
retentionMonths = retentionMonths,
|
||||
)
|
||||
|
||||
internal fun AccountSettings.toEntity(): AccountSettingsEntity = AccountSettingsEntity(
|
||||
accountId = accountId,
|
||||
signature = signature,
|
||||
signatureEnabled = signatureEnabled,
|
||||
notificationsEnabled = notificationsEnabled,
|
||||
retentionCount = retentionCount,
|
||||
retentionMonths = retentionMonths,
|
||||
)
|
||||
|
||||
internal fun Account.toImapParams(
|
||||
secret: String,
|
||||
useXoauth2: Boolean,
|
||||
strictStartTls: Boolean = true,
|
||||
): ImapConnectionParams =
|
||||
ImapConnectionParams(
|
||||
host = imap.host,
|
||||
port = imap.port,
|
||||
security = imap.security,
|
||||
username = email,
|
||||
secret = secret,
|
||||
useXoauth2 = useXoauth2,
|
||||
strictStartTls = strictStartTls,
|
||||
)
|
||||
): ImapConnectionParams = ImapConnectionParams(
|
||||
host = imap.host,
|
||||
port = imap.port,
|
||||
security = imap.security,
|
||||
username = email,
|
||||
secret = secret,
|
||||
useXoauth2 = useXoauth2,
|
||||
strictStartTls = strictStartTls,
|
||||
)
|
||||
|
||||
internal fun Account.toSmtpParams(
|
||||
secret: String,
|
||||
useXoauth2: Boolean,
|
||||
strictStartTls: Boolean = true,
|
||||
): SmtpParams =
|
||||
internal fun Account.toSmtpParams(secret: String, useXoauth2: Boolean, strictStartTls: Boolean = true): SmtpParams =
|
||||
SmtpParams(
|
||||
host = smtp.host,
|
||||
port = smtp.port,
|
||||
@@ -83,23 +105,77 @@ internal fun MessageEntity.toDomain(): Message = Message(
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = isRead,
|
||||
isStarred = isStarred,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true): MessageEntity = MessageEntity(
|
||||
id = "$accountId:$uid",
|
||||
/**
|
||||
* Maps a mailbox-list projection to the domain model. [Message.body]/[Message.isHtml] are left
|
||||
* empty because the list never renders them — the reader loads the body on demand (see
|
||||
* [MessageSummary]).
|
||||
*/
|
||||
internal fun MessageSummary.toDomain(): Message = Message(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = sender,
|
||||
senderEmail = senderEmail,
|
||||
subject = subject,
|
||||
snippet = "",
|
||||
snippet = snippet,
|
||||
body = "",
|
||||
isHtml = false,
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = isRead,
|
||||
isStarred = isFlagged,
|
||||
isStarred = isStarred,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
bodyFetched = false,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox: Boolean = true): MessageEntity =
|
||||
MessageEntity(
|
||||
id = "$accountId:$folder:$uid",
|
||||
accountId = accountId,
|
||||
sender = sender,
|
||||
senderEmail = senderEmail,
|
||||
subject = subject,
|
||||
snippet = "",
|
||||
body = "",
|
||||
isHtml = false,
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = isRead,
|
||||
isStarred = isFlagged,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
bodyFetched = false,
|
||||
uid = uid.toLongOrNull() ?: 0L,
|
||||
)
|
||||
|
||||
internal fun FolderEntity.toDomain(): Folder = Folder(
|
||||
accountId = accountId,
|
||||
fullName = fullName,
|
||||
displayName = displayName,
|
||||
role = runCatching { FolderRole.valueOf(role) }.getOrDefault(FolderRole.NORMAL),
|
||||
selectable = selectable,
|
||||
specialUse = specialUse,
|
||||
hierarchyDelimiter = hierarchyDelimiter?.firstOrNull(),
|
||||
)
|
||||
|
||||
internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEntity = FolderEntity(
|
||||
accountId = accountId,
|
||||
fullName = fullName,
|
||||
displayName = displayName,
|
||||
role = FolderRole.roleOf(fullName, displayName, attributes).name,
|
||||
selectable = selectable,
|
||||
sortOrder = sortOrder,
|
||||
specialUse = FolderRole.isServerSpecial(attributes),
|
||||
hierarchyDelimiter = hierarchyDelimiter?.toString(),
|
||||
)
|
||||
|
||||
internal fun FolderUnreadCount.toDomain(): UnreadCount = UnreadCount(
|
||||
accountId = accountId,
|
||||
folder = folder,
|
||||
count = unreadCount,
|
||||
)
|
||||
|
||||
internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
|
||||
@@ -108,6 +184,7 @@ internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
|
||||
filename = filename,
|
||||
mimeType = mimeType,
|
||||
sizeBytes = sizeBytes,
|
||||
contentId = contentId,
|
||||
)
|
||||
|
||||
internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity(
|
||||
@@ -116,6 +193,7 @@ internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = Atta
|
||||
filename = filename,
|
||||
mimeType = mimeType,
|
||||
sizeBytes = sizeBytes,
|
||||
contentId = contentId,
|
||||
)
|
||||
|
||||
internal fun DraftEntity.toDomain(): Draft = Draft(
|
||||
@@ -123,9 +201,11 @@ internal fun DraftEntity.toDomain(): Draft = Draft(
|
||||
accountId = accountId,
|
||||
to = toAddresses,
|
||||
cc = ccAddresses,
|
||||
bcc = bccAddresses,
|
||||
subject = subject,
|
||||
body = body,
|
||||
updatedAt = updatedAt,
|
||||
bodyHtml = bodyHtml,
|
||||
attachments = attachments.toOutgoingAttachments(),
|
||||
)
|
||||
|
||||
@@ -134,27 +214,45 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity(
|
||||
accountId = accountId,
|
||||
toAddresses = to,
|
||||
ccAddresses = cc,
|
||||
bccAddresses = bcc,
|
||||
subject = subject,
|
||||
body = body,
|
||||
updatedAt = updatedAt,
|
||||
attachments = attachments.toJson(),
|
||||
attachments = attachments.toOutgoingAttachmentsJson(),
|
||||
bodyHtml = bodyHtml,
|
||||
)
|
||||
|
||||
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
|
||||
private fun List<OutgoingAttachment>.toJson(): String {
|
||||
/**
|
||||
* Serializes outgoing attachments as a JSON array of `{uri, name, contentId?, isInline?}` objects
|
||||
* ("" when empty). Shared by the drafts column and the outbox column, so an inline image's
|
||||
* cid↔file pairing survives a draft save/reopen and a queued send alike. The two optional keys are
|
||||
* omitted for a plain attachment, so an old draft (written before inline images) reads back with
|
||||
* `contentId = null` / `isInline = false`.
|
||||
*/
|
||||
internal fun List<OutgoingAttachment>.toOutgoingAttachmentsJson(): String {
|
||||
if (isEmpty()) return ""
|
||||
val array = JSONArray()
|
||||
forEach { array.put(JSONObject().put("uri", it.uri).put("name", it.name)) }
|
||||
forEach { attachment ->
|
||||
val obj = JSONObject().put("uri", attachment.uri).put("name", attachment.name)
|
||||
attachment.contentId?.let { obj.put("contentId", it) }
|
||||
if (attachment.isInline) obj.put("isInline", true)
|
||||
array.put(obj)
|
||||
}
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
private fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
|
||||
internal fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
|
||||
if (isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(this)
|
||||
(0 until array.length()).map { i ->
|
||||
val obj = array.getJSONObject(i)
|
||||
OutgoingAttachment(obj.getString("uri"), obj.optString("name"))
|
||||
OutgoingAttachment(
|
||||
uri = obj.getString("uri"),
|
||||
name = obj.optString("name"),
|
||||
contentId = obj.optString("contentId").takeIf { it.isNotEmpty() },
|
||||
isInline = obj.optBoolean("isInline", false),
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -166,7 +264,9 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage(
|
||||
body = body,
|
||||
createdAt = createdAt,
|
||||
lastError = lastError,
|
||||
bodyHtml = bodyHtml,
|
||||
)
|
||||
|
||||
private fun String.toMailSecurity(): MailSecurity =
|
||||
runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS)
|
||||
private fun String.toMailSecurity(): MailSecurity = runCatching {
|
||||
MailSecurity.valueOf(this)
|
||||
}.getOrDefault(MailSecurity.SSL_TLS)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.libremail.data.local
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.libremail.data.Snippet
|
||||
|
||||
/** v1 -> v2: add the encrypted-credentials table (preserves existing accounts/messages). */
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
@@ -127,3 +128,234 @@ val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
db.execSQL("ALTER TABLE `drafts_new` RENAME TO `drafts`")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v7 -> v8: folder-aware mail (preserves existing data).
|
||||
* - `messages`: add a `folder` column (existing rows are inbox rows). The first post-upgrade INBOX
|
||||
* sync reconciles ids, which now embed the folder ("accountId:folder:uid").
|
||||
* - add the `folders` table caching each account's IMAP folder list for the navigation drawer.
|
||||
*/
|
||||
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `messages` ADD COLUMN `folder` TEXT NOT NULL DEFAULT 'INBOX'")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `folders` (" +
|
||||
"`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, " +
|
||||
"`role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`accountId`, `fullName`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v8 -> v9: per-account settings (preserves existing data). Adds the `account_settings` table with a
|
||||
* cascading foreign key to `accounts`, and backfills a default row for every existing account.
|
||||
*
|
||||
* Columns are declared without SQL DEFAULTs and the backfill lists every column explicitly (the
|
||||
* MIGRATION_4_5/5_6 pattern), so the fresh-install schema matches the migrated one — avoiding the
|
||||
* `@ColumnInfo(defaultValue)` mismatch that MIGRATION_6_7 had to repair.
|
||||
*/
|
||||
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `account_settings` (" +
|
||||
"`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, " +
|
||||
"`signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`accountId`), " +
|
||||
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO `account_settings` " +
|
||||
"(`accountId`, `signature`, `signatureEnabled`, `notificationsEnabled`) " +
|
||||
"SELECT `id`, '', 1, 1 FROM `accounts`",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v9 -> v10: bcc support for outgoing mail (preserves existing data). Adds a `bccAddresses` column
|
||||
* to `outbox` and `drafts` so a `mailto:`-launched (or manually addressed) blind-copy recipient
|
||||
* survives being queued and saved. The `DEFAULT ''` matches the entities' `@ColumnInfo(defaultValue)`
|
||||
* so the fresh-install schema validates identically to the migrated one (the MIGRATION_7_8 pattern).
|
||||
*/
|
||||
val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v10 -> v11: rich composition (preserves existing data).
|
||||
* - `drafts`/`outbox`: add a nullable `bodyHtml` column carrying the HTML form of the body when a
|
||||
* message was composed with formatting (null = plaintext-only, sent/kept exactly as before).
|
||||
* - add the `signatures` table (multiple named signatures per account, one default), with a
|
||||
* cascading foreign key to `accounts`, and backfill each account's existing per-account settings
|
||||
* signature as its default signature so nobody loses one on upgrade.
|
||||
*/
|
||||
val MIGRATION_10_11 = object : Migration(10, 11) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT")
|
||||
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `signatures` (" +
|
||||
"`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||
"`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
|
||||
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)")
|
||||
// Preserve any existing plain-text per-account signature as that account's default signature.
|
||||
// Newlines become <br> so the HTML keeps the original line breaks; other characters are rare
|
||||
// in signatures and pass through unescaped.
|
||||
db.execSQL(
|
||||
"INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " +
|
||||
"SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " +
|
||||
"replace(`signature`, char(10), '<br>'), 1 " +
|
||||
"FROM `account_settings` WHERE `signature` <> ''",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 -> v12: drawer folder de-duplication (preserves existing data). Adds a `specialUse` column to
|
||||
* `folders` recording whether the server advertises the folder as special-use (RFC 6154), so the
|
||||
* drawer can tell a provider's built-in folder from a same-named user folder. `DEFAULT 0` matches
|
||||
* the entity's `@ColumnInfo(defaultValue = "0")` so fresh-install and migrated schemas validate
|
||||
* identically (the MIGRATION_9_10 pattern); the next folder refresh backfills the real value.
|
||||
*/
|
||||
val MIGRATION_11_12 = object : Migration(11, 12) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `folders` ADD COLUMN `specialUse` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v12 -> v13: full-history backfill + device-only retention (issues #12/#13; preserves existing data).
|
||||
* - `messages`: add the materialized `uid` column (`DEFAULT 0`, matching the entity's
|
||||
* `@ColumnInfo(defaultValue = "0")`) and backfill it from the numeric tail of the existing
|
||||
* "accountId:folder:uid" id. `rtrim(id, '0123456789')` strips the trailing digits, leaving the
|
||||
* prefix up to and including the final ':'; the remainder is the UID. Non-numeric tails cast to 0
|
||||
* and are refreshed to the real UID on the next sync.
|
||||
* - `account_settings`: add nullable `retentionCount` / `retentionMonths` overrides (NULL = inherit
|
||||
* the global default), declared without SQL defaults to match the entity's nullable columns.
|
||||
* - add the `backfill_progress` table tracking each folder's paging boundary so the backfill resumes
|
||||
* after process death / network loss.
|
||||
*/
|
||||
val MIGRATION_12_13 = object : Migration(12, 13) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `messages` ADD COLUMN `uid` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL(
|
||||
"UPDATE `messages` SET `uid` = " +
|
||||
"CAST(substr(`id`, length(rtrim(`id`, '0123456789')) + 1) AS INTEGER)",
|
||||
)
|
||||
|
||||
db.execSQL("ALTER TABLE `account_settings` ADD COLUMN `retentionCount` INTEGER")
|
||||
db.execSQL("ALTER TABLE `account_settings` ADD COLUMN `retentionMonths` INTEGER")
|
||||
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `backfill_progress` (" +
|
||||
"`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, " +
|
||||
"`nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`accountId`, `folder`))",
|
||||
)
|
||||
|
||||
// Index the folder-scoped UID probes the backfill/reconcile hot paths run on every page/sync.
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` " +
|
||||
"ON `messages` (`accountId`, `folder`, `uid`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v13 -> v14: data-only, no schema change (preserves existing data). Re-derives the persisted
|
||||
* `snippet` of every message with a cached body using [Snippet.of], which — unlike the derivation
|
||||
* it replaces — respects `isHtml`: HTML rows lose leaked `<style>`/`<script>` text and literal
|
||||
* entities, plain-text rows get back any `<...>` text that was wrongly stripped as markup. A
|
||||
* snippet is only derived when a body is first fetched, so without this pass existing rows would
|
||||
* keep their broken snippets forever. Snippets are computed while the cursor streams (bodies are
|
||||
* never all held in memory) and the small id→snippet batch is applied after it closes.
|
||||
*/
|
||||
val MIGRATION_13_14 = object : Migration(13, 14) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val updates = mutableListOf<Pair<String, String>>()
|
||||
db.query("SELECT `id`, `body`, `isHtml` FROM `messages` WHERE `bodyFetched` = 1").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getString(0)
|
||||
val snippet = Snippet.of(body = cursor.getString(1), isHtml = cursor.getInt(2) != 0)
|
||||
updates += id to snippet
|
||||
}
|
||||
}
|
||||
updates.forEach { (id, snippet) ->
|
||||
db.execSQL("UPDATE `messages` SET `snippet` = ? WHERE `id` = ?", arrayOf(snippet, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v14 -> v15: persist the server-reported IMAP hierarchy delimiter (issue #66; preserves existing
|
||||
* data). Adds a nullable `hierarchyDelimiter` column to `folders` recording the separator character
|
||||
* the server reported for the folder in its LIST response (e.g. "/" for Gmail, "." for some servers),
|
||||
* so the drawer splits a folder's parent on the authoritative delimiter instead of re-inferring it
|
||||
* from the name. Nullable with no SQL default (the MIGRATION_10_11 `bodyHtml` pattern) so existing
|
||||
* rows read back null and fall back to the legacy inference until the next folder refresh backfills
|
||||
* the real delimiter (`FolderDao.replaceForAccount` re-inserts every folder on each sync).
|
||||
*/
|
||||
val MIGRATION_14_15 = object : Migration(14, 15) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `folders` ADD COLUMN `hierarchyDelimiter` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v15 -> v16: move account identity + configuration OUT of the cache database (issue #111). The
|
||||
* `accounts`, `credentials`, `account_settings` and `signatures` tables now live in [AccountDatabase]
|
||||
* — a separate file that is never sealed by the auth-bound SQLCipher key — so a cache-key invalidation
|
||||
* (biometric re-enrollment / lock removal) wipes only mail and can no longer sign the user out.
|
||||
*
|
||||
* The rows are copied into [AccountDatabase] by [AccountDataMigrator] at startup BEFORE Room opens the
|
||||
* cache and runs this migration. The copy CANNOT happen here: Room wraps each migration in a
|
||||
* transaction and SQLite forbids `ATTACH DATABASE` inside one, so a cross-database copy has to run on
|
||||
* a separate connection before the cache is opened. This migration therefore only drops the tables
|
||||
* that were moved. `DROP TABLE IF EXISTS` keeps it idempotent, and children (foreign-keyed to
|
||||
* `accounts`) are dropped before the parent so the drop never trips a foreign-key check.
|
||||
*/
|
||||
val MIGRATION_15_16 = object : Migration(15, 16) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS `signatures`")
|
||||
db.execSQL("DROP TABLE IF EXISTS `account_settings`")
|
||||
db.execSQL("DROP TABLE IF EXISTS `credentials`")
|
||||
db.execSQL("DROP TABLE IF EXISTS `accounts`")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v16 -> v17: inline-image support in the reader (issue #133; preserves existing data). Adds a
|
||||
* nullable `contentId` column to `attachments` recording the `Content-ID` of an inline image
|
||||
* (`<img src="cid:...">`) so the reader's WebView can resolve `cid:` requests to the cached bytes,
|
||||
* and so such parts can be filtered out of the user-facing attachment list. Nullable with no SQL
|
||||
* default (the MIGRATION_14_15 `hierarchyDelimiter` pattern) so existing attachment rows read back
|
||||
* null — i.e. treated as ordinary attachments — until the next fetch reclassifies them.
|
||||
*/
|
||||
val MIGRATION_16_17 = object : Migration(16, 17) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `attachments` ADD COLUMN `contentId` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v17 -> v18: inline-image support for outgoing mail (issue #77; preserves existing data). Adds an
|
||||
* `attachments` column to `outbox` holding JSON metadata (`{uri, name, contentId?, isInline?}`, one
|
||||
* entry per staged file in index order) so the send worker can pair an inline image's `Content-ID`
|
||||
* with its staged file. `DEFAULT ''` matches the entity's `@ColumnInfo(defaultValue = "")` so the
|
||||
* fresh-install schema validates identically to the migrated one (the MIGRATION_9_10 `bccAddresses`
|
||||
* pattern); a message queued before the upgrade reads back "" and its staged files are still sent as
|
||||
* plain attachments (the send worker's positional fallback).
|
||||
*/
|
||||
val MIGRATION_17_18 = object : Migration(17, 18) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `attachments` TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
|
||||
@Dao
|
||||
interface AccountSettingsDao {
|
||||
@Query("SELECT * FROM account_settings WHERE accountId = :accountId LIMIT 1")
|
||||
fun observe(accountId: String): Flow<AccountSettingsEntity?>
|
||||
|
||||
@Query("SELECT * FROM account_settings WHERE accountId = :accountId LIMIT 1")
|
||||
suspend fun get(accountId: String): AccountSettingsEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(settings: AccountSettingsEntity)
|
||||
}
|
||||
@@ -11,9 +11,21 @@ import org.libremail.data.local.entity.AttachmentEntity
|
||||
|
||||
@Dao
|
||||
interface AttachmentDao {
|
||||
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
|
||||
/**
|
||||
* The message's user-facing attachments for the reader's attachment list. Inline images
|
||||
* (`contentId IS NOT NULL`) are excluded — they render in the body via `cid:`, not as downloads
|
||||
* (issue #133).
|
||||
*/
|
||||
@Query("SELECT * FROM attachments WHERE messageId = :messageId AND contentId IS NULL ORDER BY partIndex")
|
||||
fun observeForMessage(messageId: String): Flow<List<AttachmentEntity>>
|
||||
|
||||
/**
|
||||
* One-shot read of ALL of a message's cached parts — attachments AND inline images — e.g. to
|
||||
* pre-download their bytes or resolve a `cid:` reference. The reader filters by [AttachmentEntity.contentId].
|
||||
*/
|
||||
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
|
||||
suspend fun getForMessage(messageId: String): List<AttachmentEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(attachments: List<AttachmentEntity>)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import org.libremail.data.local.entity.BackfillProgressEntity
|
||||
|
||||
@Dao
|
||||
interface BackfillProgressDao {
|
||||
@Query("SELECT * FROM backfill_progress WHERE accountId = :accountId AND folder = :folder LIMIT 1")
|
||||
suspend fun get(accountId: String, folder: String): BackfillProgressEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(progress: BackfillProgressEntity)
|
||||
|
||||
@Query("DELETE FROM backfill_progress WHERE accountId = :accountId")
|
||||
suspend fun deleteForAccount(accountId: String)
|
||||
|
||||
/** Clears all backfill progress (e.g. when the global retention default changes) so it re-evaluates. */
|
||||
@Query("DELETE FROM backfill_progress")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
|
||||
@Dao
|
||||
interface FolderDao {
|
||||
@Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC")
|
||||
fun observeForAccount(accountId: String): Flow<List<FolderEntity>>
|
||||
|
||||
/** One-shot read of an account's folders, e.g. to resolve a role folder for archive/spam/trash. */
|
||||
@Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC")
|
||||
suspend fun getForAccountOnce(accountId: String): List<FolderEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(folders: List<FolderEntity>)
|
||||
|
||||
@Query("DELETE FROM folders WHERE accountId = :accountId")
|
||||
suspend fun deleteForAccount(accountId: String)
|
||||
|
||||
/** Replaces an account's folder set with the freshly-listed one (delete-then-insert). */
|
||||
@Transaction
|
||||
suspend fun replaceForAccount(accountId: String, folders: List<FolderEntity>) {
|
||||
deleteForAccount(accountId)
|
||||
insertAll(folders)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,143 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.FolderUnreadCount
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageRouting
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
|
||||
fun observeAll(): Flow<List<MessageEntity>>
|
||||
/**
|
||||
* Mailbox-list projection ordered newest-first. Deliberately omits the large `body`/`isHtml`
|
||||
* columns: the list observes every cached message at once, and pulling full bodies through
|
||||
* SQLite's shared ~2 MB CursorWindow overflows it once enough large bodies are cached
|
||||
* (issue #51). Bodies are loaded lazily per-message via [getById] when a message is opened.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun observeSummaries(): Flow<List<MessageSummary>>
|
||||
|
||||
/**
|
||||
* Mailbox-list projection scoped in SQL to one account's [folder], newest-first. Unlike
|
||||
* [observeSummaries] this pulls only that folder's rows and re-emits only when they actually
|
||||
* change, so the mailbox list's cost scales with what's shown, not the whole cache (issue #86).
|
||||
* `inInbox` is *not* filtered here so the one query serves both the normal list (caller keeps
|
||||
* `inInbox = 1` rows) and search (caller keeps rows matching the query, including transient
|
||||
* `inInbox = 0` server-search hits). Served by the existing `(accountId, folder, uid)` index (its
|
||||
* `(accountId, folder)` prefix), so no new index — and no schema migration — is needed.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
|
||||
"WHERE accountId = :accountId AND folder = :folder ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun observeFolderSummaries(accountId: String, folder: String): Flow<List<MessageSummary>>
|
||||
|
||||
/**
|
||||
* Unified-inbox projection: [folder] across every account, newest-first. Scoped in SQL like
|
||||
* [observeFolderSummaries] but without an account predicate (issue #86). No existing index leads
|
||||
* with `folder`, so this still scans in timestamp order — far cheaper than [observeSummaries] (it
|
||||
* materializes only this folder's rows, not the whole cache) but not O(1); a large multi-account
|
||||
* unified inbox is a candidate for a `(folder, inInbox, timestampMillis)` index + paging.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
|
||||
"WHERE folder = :folder ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun observeUnifiedFolderSummaries(folder: String): Flow<List<MessageSummary>>
|
||||
|
||||
/**
|
||||
* Paged unified-inbox projection: folder-synced rows of [folder] across every account,
|
||||
* newest-first, as a Paging 3 [PagingSource] (issue #124). Unlike [observeUnifiedFolderSummaries]
|
||||
* — which materializes the *entire* unified inbox (~thousands of rows) on every emission — Room
|
||||
* loads only the requested window (LIMIT/OFFSET), so the mailbox list's query, mapping, and
|
||||
* recomposition cost scale with what's on screen, not the whole cache. Filters `inInbox = 1`
|
||||
* because the paged browse list shows only synced rows; unified *search* (which must also surface
|
||||
* transient `inInbox = 0` hits) stays on [observeUnifiedFolderSummaries]. Profiling (see
|
||||
* `docs/perf/issue-124-unified-inbox-paging.md`) showed the first page loads flat regardless of
|
||||
* total cache size on the existing indices, so no `(folder, …)` index / schema migration is added.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
|
||||
"WHERE folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun pagingUnifiedFolderSummaries(folder: String): PagingSource<Int, MessageSummary>
|
||||
|
||||
/**
|
||||
* Live per-(account, folder) unread counts for the drawer's folder badges and the bold styling of
|
||||
* accounts with unread mail. Counts only folder-synced rows (`inInbox = 1`), so transient
|
||||
* server-search hits never inflate a badge; read rows and folders with no unread mail are simply
|
||||
* absent from the result. A pure `COUNT(*)` aggregate — no message rows are pulled into memory —
|
||||
* whose `GROUP BY accountId, folder` is served by the existing `(accountId, folder, uid)` index.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT accountId, folder, COUNT(*) AS unreadCount FROM messages " +
|
||||
"WHERE inInbox = 1 AND isRead = 0 GROUP BY accountId, folder",
|
||||
)
|
||||
fun observeUnreadCounts(): Flow<List<FolderUnreadCount>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): MessageEntity?
|
||||
|
||||
/** Ids of an account's inbox rows (excludes transient server-search hits). */
|
||||
@Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1")
|
||||
suspend fun getInboxIdsForAccount(accountId: String): List<String>
|
||||
/**
|
||||
* Body-less routing/flags projection for a single message (issue #186). The open path and the
|
||||
* flag/move callers only need routing and flag columns, so pulling the whole `body` through
|
||||
* SQLite's shared CursorWindow on every such read is pure over-fetch — [getById] (`SELECT *`) is
|
||||
* reserved for the one read that actually returns the body to the reader. Served by the primary-key
|
||||
* lookup, so no new index / migration (mirrors the [MessageSummary] projection).
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml FROM messages " +
|
||||
"WHERE id = :id LIMIT 1",
|
||||
)
|
||||
suspend fun getRouting(id: String): MessageRouting?
|
||||
|
||||
/** Body-less routing/flags projection for a set of messages — batch move/delete/expunge callers. */
|
||||
@Query(
|
||||
"SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml FROM messages " +
|
||||
"WHERE id IN (:ids)",
|
||||
)
|
||||
suspend fun getRoutingByIds(ids: List<String>): List<MessageRouting>
|
||||
|
||||
/** Ids of an account's synced rows in [folder] (excludes transient server-search hits). */
|
||||
@Query("SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
|
||||
suspend fun getSyncedIds(accountId: String, folder: String): List<String>
|
||||
|
||||
/** Ids of synced rows in [folder] whose body hasn't been cached yet (for aggressive prefetch). */
|
||||
@Query(
|
||||
"SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder " +
|
||||
"AND inInbox = 1 AND bodyFetched = 0",
|
||||
)
|
||||
suspend fun getUnfetchedIds(accountId: String, folder: String): List<String>
|
||||
|
||||
/** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertNew(messages: List<MessageEntity>)
|
||||
|
||||
/** Of the given [ids], those that already have a row — lets a caller refresh only pre-existing rows. */
|
||||
@Query("SELECT id FROM messages WHERE id IN (:ids)")
|
||||
suspend fun existingIds(ids: List<String>): List<String>
|
||||
|
||||
/**
|
||||
* Refreshes the display fields from the server without touching the cached body, the local
|
||||
* read/star flags (which may hold an optimistic change the server hasn't reflected yet), or the
|
||||
* inbox membership.
|
||||
* Refreshes the display fields (and the materialized [MessageEntity.uid], keeping it fresh for
|
||||
* rows migrated before the column existed) from the server without touching the cached body, the
|
||||
* local read/star flags (which may hold an optimistic change the server hasn't reflected yet), or
|
||||
* the inbox membership.
|
||||
*/
|
||||
@Query(
|
||||
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
|
||||
"timestampMillis = :timestampMillis WHERE id = :id",
|
||||
"timestampMillis = :timestampMillis, uid = :uid WHERE id = :id",
|
||||
)
|
||||
suspend fun updateHeaderContent(
|
||||
id: String,
|
||||
@@ -39,11 +145,12 @@ interface MessageDao {
|
||||
senderEmail: String,
|
||||
subject: String,
|
||||
timestampMillis: Long,
|
||||
uid: Long,
|
||||
)
|
||||
|
||||
/** Marks rows as belonging to the inbox (e.g. a former search-only row that the sync now returns). */
|
||||
/** Marks rows as folder-synced (e.g. a former search-only row that the sync now returns). */
|
||||
@Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)")
|
||||
suspend fun markInInbox(ids: List<String>)
|
||||
suspend fun markSynced(ids: List<String>)
|
||||
|
||||
@Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id")
|
||||
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String)
|
||||
@@ -57,16 +164,70 @@ interface MessageDao {
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
/** Optimistically removes moved/deleted rows; the next sync reconciles if a server op failed. */
|
||||
@Query("DELETE FROM messages WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<String>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE accountId = :accountId")
|
||||
suspend fun deleteByAccount(accountId: String)
|
||||
|
||||
/** Clears only an account's inbox rows (leaves any in-flight search-only rows). */
|
||||
@Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1")
|
||||
suspend fun deleteInboxByAccount(accountId: String)
|
||||
/** Clears an account's synced rows in [folder] (leaves any in-flight search-only rows). */
|
||||
@Query("DELETE FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
|
||||
suspend fun deleteSyncedByAccountFolder(accountId: String, folder: String)
|
||||
|
||||
/** Drops inbox rows for an account that are no longer present on the server. */
|
||||
@Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1 AND id NOT IN (:keepIds)")
|
||||
suspend fun deleteInboxNotIn(accountId: String, keepIds: List<String>)
|
||||
/**
|
||||
* Windowed deletion reconcile for full-history sync (issue #12): within [folder], delete synced
|
||||
* rows whose UID falls inside the freshly-fetched recent window (`uid >= minWindowUid`) but which
|
||||
* the server no longer returns ([keepIds]). Rows below the window — older history fetched by the
|
||||
* background backfill — are deliberately left intact, unlike a whole-folder "not in the recent
|
||||
* set" reconcile, which would wipe that backfilled history.
|
||||
*/
|
||||
@Query(
|
||||
"DELETE FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " +
|
||||
"AND uid >= :minWindowUid AND id NOT IN (:keepIds)",
|
||||
)
|
||||
suspend fun deleteSyncedInWindowNotIn(accountId: String, folder: String, minWindowUid: Long, keepIds: List<String>)
|
||||
|
||||
/**
|
||||
* Lowest cached *resolved* UID among an account's synced rows in [folder] — the backfill
|
||||
* boundary. Placeholder rows with `uid <= 0` (a row migrated before the `uid` column existed, or
|
||||
* a fetch where the server failed to resolve the UID) are excluded: letting one collapse
|
||||
* MIN(uid) to `<= 0` would make the backfiller page below a bound `fetchOlderThan` treats as
|
||||
* "nothing older", falsely marking the folder fully backfilled (#95, matching the
|
||||
* `minWindowUid` guard in MailSyncer). Null when no resolved-UID row exists, in which case
|
||||
* backfill starts over from the newest message.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT MIN(uid) FROM messages WHERE accountId = :accountId AND folder = :folder " +
|
||||
"AND inInbox = 1 AND uid > 0",
|
||||
)
|
||||
suspend fun lowestSyncedUid(accountId: String, folder: String): Long?
|
||||
|
||||
/** Number of an account's synced rows in [folder] (count-based retention floor / prune sizing). */
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
|
||||
suspend fun countSynced(accountId: String, folder: String): Int
|
||||
|
||||
/** Distinct folders that have at least one synced row for [accountId] (backfill/prune targets). */
|
||||
@Query("SELECT DISTINCT folder FROM messages WHERE accountId = :accountId AND inInbox = 1")
|
||||
suspend fun syncedFolders(accountId: String): List<String>
|
||||
|
||||
/** Ids of an account's synced rows older than [cutoffMillis] (age-based prune candidates, all folders). */
|
||||
@Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1 AND timestampMillis < :cutoffMillis")
|
||||
suspend fun syncedIdsOlderThan(accountId: String, cutoffMillis: Long): List<String>
|
||||
|
||||
/**
|
||||
* Ids of an account's synced rows in [folder] beyond the newest [keep] by ARRIVAL (server UID —
|
||||
* count-based prune candidates). Ordering by UID (not by the Date header) matches the newest-by-UID
|
||||
* recent window [org.libremail.mail.ImapClient.fetchRecent] keeps fresh, so a message with a high
|
||||
* UID but an old Date isn't re-fetched by every sync and re-pruned by every cycle.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " +
|
||||
"AND id NOT IN (" +
|
||||
"SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " +
|
||||
"ORDER BY uid DESC LIMIT :keep)",
|
||||
)
|
||||
suspend fun syncedIdsBeyondCountInFolder(accountId: String, folder: String, keep: Int): List<String>
|
||||
|
||||
/** Removes transient server-search hits (called when search closes). */
|
||||
@Query("DELETE FROM messages WHERE inInbox = 0")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
@Dao
|
||||
interface SignatureDao {
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE")
|
||||
fun observeForAccount(accountId: String): Flow<List<SignatureEntity>>
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE id = :id LIMIT 1")
|
||||
suspend fun getById(id: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1")
|
||||
suspend fun getDefault(accountId: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1")
|
||||
suspend fun firstForAccount(accountId: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId")
|
||||
suspend fun countForAccount(accountId: String): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(signature: SignatureEntity)
|
||||
|
||||
@Query("DELETE FROM signatures WHERE id = :id")
|
||||
suspend fun delete(id: String)
|
||||
|
||||
@Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId")
|
||||
suspend fun clearDefault(accountId: String)
|
||||
|
||||
@Query("UPDATE signatures SET isDefault = 1 WHERE id = :id")
|
||||
suspend fun markDefault(id: String)
|
||||
|
||||
/** Makes [id] the account's sole default in one transaction (clears the others first). */
|
||||
@Transaction
|
||||
suspend fun setDefault(accountId: String, id: String) {
|
||||
clearDefault(accountId)
|
||||
markDefault(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Per-account preferences, one row per account. The [accountId] foreign key cascades on delete, so
|
||||
* removing an account also removes its settings. It is the primary key, so it is already indexed
|
||||
* (no extra `@Index` needed for the foreign key).
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "account_settings",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AccountEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["accountId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class AccountSettingsEntity(
|
||||
@PrimaryKey val accountId: String,
|
||||
val signature: String = "",
|
||||
val signatureEnabled: Boolean = true,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
/**
|
||||
* Per-account device-only retention overrides (issue #13). `null` means "use the global default";
|
||||
* `0` means an explicit "keep everything" (no limit). A positive value caps how many messages
|
||||
* ([retentionCount], newest per folder) or how many months of history ([retentionMonths]) are kept
|
||||
* on this device — the server copy is never touched.
|
||||
*/
|
||||
val retentionCount: Int? = null,
|
||||
val retentionMonths: Int? = null,
|
||||
)
|
||||
@@ -25,4 +25,10 @@ data class AttachmentEntity(
|
||||
val filename: String,
|
||||
val mimeType: String,
|
||||
val sizeBytes: Long,
|
||||
/**
|
||||
* The normalized `Content-ID` when this part is an inline image (`<img src="cid:...">`) — null for
|
||||
* an ordinary attachment. Inline rows are cached so the reader's WebView can resolve `cid:`
|
||||
* requests offline, but are filtered out of the displayed attachment list (issue #133).
|
||||
*/
|
||||
val contentId: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
|
||||
/**
|
||||
* Per-(account, folder) progress of the full-history backfill (issue #12). Lets the background
|
||||
* backfill worker page backwards through a folder resumably: it survives process death and network
|
||||
* loss because the boundary is persisted after every batch.
|
||||
*
|
||||
* Not foreign-keyed to `accounts` (matching `messages`/`folders`); it is cleared explicitly when an
|
||||
* account is removed.
|
||||
*/
|
||||
@Entity(tableName = "backfill_progress", primaryKeys = ["accountId", "folder"])
|
||||
data class BackfillProgressEntity(
|
||||
val accountId: String,
|
||||
val folder: String,
|
||||
/**
|
||||
* Exclusive upper UID bound for the next page: the next batch fetches server messages with
|
||||
* UID < this value. Lowered to the batch's lowest UID after each successful page.
|
||||
*/
|
||||
val nextBeforeUid: Long,
|
||||
/** True once the whole folder (down to the retention floor, if any) has been cached. */
|
||||
val complete: Boolean = false,
|
||||
)
|
||||
@@ -10,7 +10,4 @@ import androidx.room.PrimaryKey
|
||||
* plaintext.
|
||||
*/
|
||||
@Entity(tableName = "credentials")
|
||||
data class CredentialEntity(
|
||||
@PrimaryKey val accountId: String,
|
||||
val encryptedSecret: String,
|
||||
)
|
||||
data class CredentialEntity(@PrimaryKey val accountId: String, val encryptedSecret: String)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@@ -11,9 +12,12 @@ data class DraftEntity(
|
||||
val accountId: String?,
|
||||
val toAddresses: String,
|
||||
val ccAddresses: String,
|
||||
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
|
||||
val subject: String,
|
||||
val body: String,
|
||||
val updatedAt: Long,
|
||||
/** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */
|
||||
val attachments: String = "",
|
||||
/** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */
|
||||
val bodyHtml: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
|
||||
/** A cached IMAP folder for an account. [sortOrder] preserves the server's listing order. */
|
||||
@Entity(tableName = "folders", primaryKeys = ["accountId", "fullName"])
|
||||
data class FolderEntity(
|
||||
val accountId: String,
|
||||
val fullName: String,
|
||||
val displayName: String,
|
||||
/** The [org.libremail.domain.model.FolderRole] name. */
|
||||
val role: String,
|
||||
val selectable: Boolean,
|
||||
val sortOrder: Int,
|
||||
/**
|
||||
* True when the server advertises this as a special-use folder (RFC 6154, e.g. `\Drafts`,
|
||||
* `\Junk`, `\All`). Distinguishes a provider's built-in folder from a same-named user folder
|
||||
* when the drawer de-duplicates display labels.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0") val specialUse: Boolean = false,
|
||||
/**
|
||||
* The hierarchy separator the server reported for this folder in its LIST response (e.g. "/" for
|
||||
* Gmail, "." for some servers), stored as a one-character string. Null for folders cached before
|
||||
* this column existed (legacy rows) or on a flat namespace; the drawer's parent-label logic then
|
||||
* infers the separator from the folder name (issue #66). Nullable with no SQL default (the same
|
||||
* pattern as the drafts `bodyHtml` column), so the next folder refresh backfills the real value.
|
||||
*/
|
||||
val hierarchyDelimiter: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
/**
|
||||
* Aggregate projection of [MessageEntity] counting unread, folder-synced rows per (account, folder).
|
||||
* Produced by `MessageDao.observeUnreadCounts`' `GROUP BY` query — never a stored table — so the
|
||||
* counts come straight from SQLite without pulling any message rows into memory.
|
||||
*/
|
||||
data class FolderUnreadCount(val accountId: String, val folder: String, val unreadCount: Int)
|
||||
@@ -1,13 +1,17 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "messages",
|
||||
indices = [Index("accountId"), Index("timestampMillis")],
|
||||
// The (accountId, folder, uid) index serves the folder-scoped UID probes the backfill/reconcile
|
||||
// hot paths run on every page/sync: MIN(uid) (lowestSyncedUid) and the uid >= window bound
|
||||
// (deleteSyncedInWindowNotIn / syncedIdsBeyondCountInFolder).
|
||||
indices = [Index("accountId"), Index("timestampMillis"), Index("accountId", "folder", "uid")],
|
||||
)
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@@ -21,8 +25,17 @@ data class MessageEntity(
|
||||
val timestampMillis: Long,
|
||||
val isRead: Boolean,
|
||||
val isStarred: Boolean,
|
||||
/** True for inbox-synced rows; false for transient server-search hits (purged on search close). */
|
||||
/** The folder this message belongs to, e.g. "INBOX" or "[Gmail]/Sent Mail". */
|
||||
@ColumnInfo(defaultValue = "INBOX") val folder: String = "INBOX",
|
||||
/** True for folder-synced rows; false for transient server-search hits (purged on search close). */
|
||||
val inInbox: Boolean = true,
|
||||
/** True once the body has been fetched from the server (distinguishes "not fetched" from "empty body"). */
|
||||
val bodyFetched: Boolean = false,
|
||||
/**
|
||||
* The server IMAP UID as a number (also embedded in [id]). Materialized as a column so full-history
|
||||
* backfill can page by "lowest cached UID" and foreground sync can reconcile only the recent UID
|
||||
* window without deleting older, backfilled history. 0 for rows migrated before this column existed
|
||||
* (refreshed to the real UID on the next sync).
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0") val uid: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
/**
|
||||
* Body-less projection of [MessageEntity] for the repository's routing and flag decisions: every
|
||||
* field needed to decide whether to fetch a body, push a SEEN/FLAGGED change, or resolve the
|
||||
* message's folder/account — but *not* the potentially large `body` blob.
|
||||
*
|
||||
* The open path and the flag/move callers (`openMessage`, `downloadAttachment`, `setStarred`,
|
||||
* `deleteMessage`, `expunge`, `moveByRole`, `moveToFolder`, `buildReplyDraft`, `prefetchMessage`)
|
||||
* never render the body, so pulling it through SQLite's shared CursorWindow on every such read is
|
||||
* pure over-fetch (issue #186). `MessageDao.getById` (`SELECT *`) is reserved for the one read that
|
||||
* actually returns the body to the reader. Mirrors the existing [MessageSummary] projection — served
|
||||
* by the primary-key lookup, so no new index and no schema migration.
|
||||
*/
|
||||
data class MessageRouting(
|
||||
val id: String,
|
||||
val accountId: String,
|
||||
val folder: String,
|
||||
val uid: Long,
|
||||
val isRead: Boolean,
|
||||
val isStarred: Boolean,
|
||||
val bodyFetched: Boolean,
|
||||
val isHtml: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
/**
|
||||
* Lightweight projection of [MessageEntity] for the mailbox list: every column the list renders
|
||||
* or searches, but *not* the potentially large `body`/`isHtml`.
|
||||
*
|
||||
* The list observes every cached message at once, so selecting full HTML bodies would drag them
|
||||
* all through SQLite's shared (~2 MB) CursorWindow and overflow it once enough large bodies are
|
||||
* cached — crashing with "Couldn't read row N from CursorWindow" (issue #51). Bodies are read
|
||||
* lazily, one message at a time, via `MessageDao.getById` when a message is opened.
|
||||
*/
|
||||
data class MessageSummary(
|
||||
val id: String,
|
||||
val accountId: String,
|
||||
val sender: String,
|
||||
val senderEmail: String,
|
||||
val subject: String,
|
||||
val snippet: String,
|
||||
val timestampMillis: Long,
|
||||
val isRead: Boolean,
|
||||
val isStarred: Boolean,
|
||||
val folder: String,
|
||||
val inInbox: Boolean,
|
||||
val bodyFetched: Boolean,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@@ -11,8 +12,18 @@ data class OutboxEntity(
|
||||
val accountId: String,
|
||||
val toAddresses: String,
|
||||
val ccAddresses: String,
|
||||
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
|
||||
val subject: String,
|
||||
val body: String,
|
||||
val createdAt: Long,
|
||||
val lastError: String? = null,
|
||||
/** HTML form of [body] when composed with formatting; null sends `text/plain` only. */
|
||||
val bodyHtml: String? = null,
|
||||
/**
|
||||
* JSON metadata for the staged attachments, in staging-index order (the same
|
||||
* `{uri, name, contentId?, isInline?}` shape drafts use), or "" when there are none. The bytes
|
||||
* live on disk under `cacheDir/outbox/<id>/<index>/`; this column carries each part's `Content-ID`
|
||||
* and inline flag so the send worker can pair an inline image's cid with its file.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "") val attachments: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops
|
||||
* its signatures. [isDefault] marks the one auto-inserted when composing from the account; the
|
||||
* repository keeps at most one default per account.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "signatures",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = AccountEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["accountId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("accountId")],
|
||||
)
|
||||
data class SignatureEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val accountId: String,
|
||||
val name: String,
|
||||
val contentHtml: String,
|
||||
val isDefault: Boolean = false,
|
||||
)
|
||||
@@ -1,43 +1,56 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.repository
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.BackfillProgressDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.local.toImapParams
|
||||
import org.libremail.data.security.CredentialStore
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccountRepositoryImpl @Inject constructor(
|
||||
private val accountDao: AccountDao,
|
||||
private val messageDao: MessageDao,
|
||||
private val folderDao: FolderDao,
|
||||
private val backfillProgressDao: BackfillProgressDao,
|
||||
private val credentialStore: CredentialStore,
|
||||
private val imapClient: ImapClient,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val mailNotifier: MailNotifier,
|
||||
) : AccountRepository {
|
||||
|
||||
override fun observeAccounts(): Flow<List<Account>> =
|
||||
accountDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
override fun observeAccounts(): Flow<List<Account>> = accountDao.observeAll().map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> =
|
||||
runCatching { imapClient.listFolders(params) }
|
||||
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> = runCatching {
|
||||
imapClient.listFolders(params).map { it.fullName }
|
||||
}
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = runCatching {
|
||||
val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false))
|
||||
accountDao.upsert(account.toEntity())
|
||||
accountSettingsRepository.ensureDefaults(account.id)
|
||||
credentialStore.saveSecret(account.id, password)
|
||||
mailNotifier.ensureAccountChannel(account)
|
||||
syncScheduler.syncNow()
|
||||
folders
|
||||
syncScheduler.backfillNow() // start caching this account's full history in the background (#12)
|
||||
folders.map { it.fullName }
|
||||
}
|
||||
|
||||
override suspend fun addOutlookAccount(
|
||||
@@ -48,15 +61,27 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
val account = Account.outlook(email)
|
||||
val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true))
|
||||
accountDao.upsert(account.toEntity())
|
||||
accountSettingsRepository.ensureDefaults(account.id)
|
||||
credentialStore.saveSecret(account.id, authStateJson)
|
||||
mailNotifier.ensureAccountChannel(account)
|
||||
syncScheduler.syncNow()
|
||||
folders
|
||||
syncScheduler.backfillNow() // start caching this account's full history in the background (#12)
|
||||
folders.map { it.fullName }
|
||||
}
|
||||
|
||||
override suspend fun deleteAccount(id: String) {
|
||||
accountDao.deleteById(id)
|
||||
credentialStore.delete(id)
|
||||
// Remove the account's cached mail (attachment rows cascade via the foreign key).
|
||||
mailNotifier.deleteAccountChannel(id)
|
||||
// Remove the account's cached mail (attachment rows cascade via the foreign key), folders, and
|
||||
// backfill progress. The account_settings row is removed automatically by its cascading FK.
|
||||
messageDao.deleteByAccount(id)
|
||||
folderDao.deleteForAccount(id)
|
||||
backfillProgressDao.deleteForAccount(id)
|
||||
}
|
||||
|
||||
override suspend fun resetBackfillProgress(accountId: String?) {
|
||||
if (accountId != null) backfillProgressDao.deleteForAccount(accountId) else backfillProgressDao.deleteAll()
|
||||
syncScheduler.backfillNow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,34 +3,58 @@ package org.libremail.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.map
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import jakarta.mail.Flags
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.data.ReplyBuilder
|
||||
import org.libremail.data.SignatureBlock
|
||||
import org.libremail.data.Snippet
|
||||
import org.libremail.data.attachmentCacheDir
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageRouting
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.local.toOutgoingAttachmentsJson
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.data.sync.SendScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.Folder
|
||||
import org.libremail.domain.model.FolderRole
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.InlineImage
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.OutboxMessage
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.domain.model.UnreadCount
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.DownloadedAttachment
|
||||
import org.libremail.mail.ImapClient
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MailRepositoryImpl @Inject constructor(
|
||||
@@ -40,57 +64,329 @@ class MailRepositoryImpl @Inject constructor(
|
||||
private val attachmentDao: AttachmentDao,
|
||||
private val outboxDao: OutboxDao,
|
||||
private val draftDao: DraftDao,
|
||||
private val folderDao: FolderDao,
|
||||
private val imapClient: ImapClient,
|
||||
private val connectionFactory: MailConnectionFactory,
|
||||
private val sendScheduler: SendScheduler,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
) : MailRepository {
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> =
|
||||
messageDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
// Application-lifetime scope for fire-and-forget server pushes that must outlive the caller — e.g.
|
||||
// openMessage() returning to the reader screen before the SEEN flag reaches the server (#148). Same
|
||||
// pattern as LibreMailApplication.appScope / IdleService.scope: this class is @Singleton (bound to
|
||||
// Hilt's SingletonComponent), so the scope's lifetime is the process's, not any one caller's coroutine.
|
||||
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
|
||||
messageDao.observeFolderSummaries(accountId, folder).map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
|
||||
messageDao.observeUnifiedFolderSummaries(folder).map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> = Pager(
|
||||
config = PagingConfig(
|
||||
// A page comfortably exceeds a screenful so scrolling rarely waits on a load; loading
|
||||
// three pages up front fills the first viewport without a visible gap. Placeholders
|
||||
// are off: the row height varies (snippet/account label), so a fixed-height placeholder
|
||||
// would jump, and the list never needs a scrollbar sized to the full (uncounted) inbox.
|
||||
pageSize = MAILBOX_PAGE_SIZE,
|
||||
initialLoadSize = MAILBOX_PAGE_SIZE * 3,
|
||||
enablePlaceholders = false,
|
||||
),
|
||||
pagingSourceFactory = { messageDao.pagingUnifiedFolderSummaries(folder) },
|
||||
).flow.map { page -> page.map { it.toDomain() } }
|
||||
|
||||
override fun observeFolders(accountId: String): Flow<List<Folder>> =
|
||||
folderDao.observeForAccount(accountId).map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = messageDao.observeUnreadCounts().map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun refreshFolders(accountId: String): Result<Unit> = runCatching {
|
||||
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val entities = imapClient.listFolders(params)
|
||||
.mapIndexed { index, folder -> folder.toEntity(accountId, index) }
|
||||
folderDao.replaceForAccount(accountId, entities)
|
||||
}
|
||||
|
||||
override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain()
|
||||
|
||||
override suspend fun openMessage(id: String): Result<Message> = runCatching {
|
||||
val entity = messageDao.getById(id) ?: error("Message not found")
|
||||
val account = accountDao.getById(entity.accountId)?.toDomain()
|
||||
if (account != null) {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
if (!entity.bodyFetched) {
|
||||
val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id))
|
||||
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
|
||||
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
|
||||
messageDao.setRead(id, true)
|
||||
} else if (!entity.isRead) {
|
||||
runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) }
|
||||
messageDao.setRead(id, true)
|
||||
override suspend fun openMessage(id: String): Result<Message> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// Route on the body-less projection: a cached, already-read message needs no account, no
|
||||
// credentials, and no network, so it skips the Keystore decrypt + DataStore read that
|
||||
// resolving connection params costs (issue #186). Only the fetch / SEEN-push branches below
|
||||
// pull the account and resolve params, and each does so lazily right where it is needed.
|
||||
val routing = messageDao.getRouting(id) ?: error("Message not found")
|
||||
if (!routing.bodyFetched || !routing.isRead) {
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain()
|
||||
if (account != null && !routing.bodyFetched) {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val content = imapClient.fetchBodyMarkingSeen(params, routing.folder, uidOf(id))
|
||||
messageDao.updateBody(id, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
|
||||
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
|
||||
messageDao.setRead(id, true)
|
||||
} else if (account != null) {
|
||||
// Optimistic, local-only: the reader can render as soon as this returns. The SEEN flag
|
||||
// still needs to reach the server, but that IMAP round trip (connection + STORE) must not
|
||||
// sit on this path (#148/#186) — the body/attachments are already fully local. Pushed on
|
||||
// backgroundScope, which outlives this call.
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
messageDao.setRead(id, true)
|
||||
pushSeenFlagInBackground(params, routing.folder, id)
|
||||
}
|
||||
}
|
||||
// The single full-body read, reserved for the value the reader actually renders (issue #186).
|
||||
messageDao.getById(id)?.toDomain() ?: error("Message not found")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, fire-and-forget propagation of the SEEN flag to the server, off the message-open
|
||||
* critical path (#148). Retries a few times with a short backoff, then gives up silently: local state
|
||||
* is already correct (the caller set it before launching this), so a permanent failure here just means
|
||||
* the server's copy stays "unread" until something else touches the flag — e.g. the message is opened
|
||||
* from another client, or a future sync gains upward read-state reconciliation. Today's folder sync
|
||||
* does NOT do that: it deliberately leaves cached read/star flags alone when refreshing headers from
|
||||
* the server (see `MessageDao.updateHeaderContent`), so it protects an optimistic local flag from
|
||||
* being clobbered by stale server state, but it does not re-drive a push that never reached the server
|
||||
* either. This retry is in-memory only and does not survive process death mid-backoff.
|
||||
*/
|
||||
private fun pushSeenFlagInBackground(params: ImapConnectionParams, folder: String, id: String) {
|
||||
backgroundScope.launch {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
attempt++
|
||||
val result = runCatching { imapClient.setFlag(params, folder, uidOf(id), Flags.Flag.SEEN, true) }
|
||||
if (result.isSuccess || attempt >= SEEN_FLAG_PUSH_MAX_ATTEMPTS) return@launch
|
||||
delay(SEEN_FLAG_RETRY_BACKOFF_MS * attempt)
|
||||
}
|
||||
}
|
||||
messageDao.getById(id)?.toDomain() ?: error("Message not found")
|
||||
}
|
||||
|
||||
override fun observeAttachments(messageId: String): Flow<List<Attachment>> =
|
||||
attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } }
|
||||
attachmentDao.observeForMessage(messageId).map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> = runCatching {
|
||||
val entity = messageDao.getById(messageId) ?: error("Message not found")
|
||||
val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found")
|
||||
override suspend fun inlineImages(messageId: String): List<InlineImage> = withContext(Dispatchers.IO) {
|
||||
// Resolve the message's account/folder once (body-less), then reuse the on-disk cache per cid:
|
||||
// image — no per-image message re-read or attachment re-query (the old downloadAttachment N+1, #186).
|
||||
val routing = messageDao.getRouting(messageId) ?: return@withContext emptyList()
|
||||
val parts = attachmentDao.getForMessage(messageId)
|
||||
parts.filter { it.contentId != null }.mapNotNull { row ->
|
||||
// Reuse the on-disk attachment cache (download once, then instant + offline). A failed
|
||||
// fetch just omits that image, leaving a broken <img> rather than failing the open.
|
||||
val file = runCatching {
|
||||
ensureAttachmentFile(messageId, routing.accountId, routing.folder, row.partIndex, row.filename)
|
||||
}.getOrNull() ?: return@mapNotNull null
|
||||
InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
|
||||
val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex }
|
||||
ensureAttachmentFile(
|
||||
messageId,
|
||||
routing.accountId,
|
||||
routing.folder,
|
||||
partIndex,
|
||||
meta?.filename ?: "attachment",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the on-disk file for one attachment part, downloading and caching it on first use so it
|
||||
* then opens instantly and offline. Takes the message's already-resolved account/folder so a batch
|
||||
* loop (e.g. [inlineImages]) resolves them once instead of re-reading the message row per part (#186).
|
||||
* Connection params are resolved lazily — only when the file is missing and must be fetched.
|
||||
*/
|
||||
private suspend fun ensureAttachmentFile(
|
||||
messageId: String,
|
||||
accountId: String,
|
||||
folder: String,
|
||||
partIndex: Int,
|
||||
filename: String,
|
||||
): File {
|
||||
val target = attachmentFile(messageId, partIndex, filename)
|
||||
// Reuse a previously downloaded (or pre-fetched) file so it opens instantly and offline.
|
||||
if (target.exists() && target.length() > 0L) return target
|
||||
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
saveToCache(imapClient.fetchAttachment(params, uidOf(messageId), partIndex))
|
||||
val downloaded = imapClient.fetchAttachment(params, folder, uidOf(messageId), partIndex)
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { it.write(downloaded.bytes) }
|
||||
return target
|
||||
}
|
||||
|
||||
override suspend fun downloadedAttachmentParts(messageId: String): Set<Int> = withContext(Dispatchers.IO) {
|
||||
attachmentDao.getForMessage(messageId)
|
||||
.filter {
|
||||
val file = attachmentFile(messageId, it.partIndex, it.filename)
|
||||
file.exists() && file.length() > 0L
|
||||
}
|
||||
.map { it.partIndex }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override suspend fun prefetchMessage(messageId: String): Result<Unit> = runCatching {
|
||||
val routing = messageDao.getRouting(messageId) ?: return@runCatching
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain() ?: return@runCatching
|
||||
// Cache the body (peek, so prefetching never marks the message read) and its attachment metadata.
|
||||
if (!routing.bodyFetched) {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val content = imapClient.fetchBodyPeek(params, routing.folder, uidOf(messageId))
|
||||
messageDao.updateBody(messageId, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
|
||||
attachmentDao.replaceForMessage(messageId, content.attachments.map { it.toEntity(messageId) })
|
||||
}
|
||||
// Auto-download every attachment's bytes into the persistent per-part cache (skips ones present).
|
||||
attachmentDao.getForMessage(messageId).forEach { attachment ->
|
||||
downloadAttachment(messageId, attachment.partIndex)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = runCatching {
|
||||
messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure
|
||||
accountFor(id)?.let { account ->
|
||||
imapClient.setFlag(connectionFactory.imapParamsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred)
|
||||
val routing = messageDao.getRouting(id)
|
||||
val account = routing?.let { accountDao.getById(it.accountId)?.toDomain() }
|
||||
if (routing != null && account != null) {
|
||||
imapClient.setFlag(
|
||||
connectionFactory.imapParamsFor(account),
|
||||
routing.folder,
|
||||
uidOf(id),
|
||||
Flags.Flag.FLAGGED,
|
||||
starred,
|
||||
)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun deleteMessage(id: String): Result<Unit> = runCatching {
|
||||
val account = accountFor(id)
|
||||
val routing = messageDao.getRouting(id)
|
||||
val account = routing?.let { accountDao.getById(it.accountId)?.toDomain() }
|
||||
messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed
|
||||
account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) }
|
||||
Unit
|
||||
if (routing != null && account != null) {
|
||||
imapClient.deleteMessage(connectionFactory.imapParamsFor(account), routing.folder, uidOf(id))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun archive(ids: List<String>): Result<Unit> =
|
||||
moveByRole(ids, FolderRole.ARCHIVE, fallbackExpunge = false)
|
||||
|
||||
override suspend fun reportSpam(ids: List<String>): Result<Unit> =
|
||||
moveByRole(ids, FolderRole.SPAM, fallbackExpunge = false)
|
||||
|
||||
override suspend fun trash(ids: List<String>): Result<Unit> =
|
||||
moveByRole(ids, FolderRole.TRASH, fallbackExpunge = true)
|
||||
|
||||
override suspend fun expunge(ids: List<String>): Result<Unit> = runCatching {
|
||||
val routings = messageDao.getRoutingByIds(ids)
|
||||
messageDao.deleteByIds(ids) // optimistic
|
||||
forEachAccountFolder(routings) { params, folder, group ->
|
||||
group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moveToFolder(ids: List<String>, destFolderFullName: String): Result<Unit> = runCatching {
|
||||
val routings = messageDao.getRoutingByIds(ids)
|
||||
messageDao.deleteByIds(ids) // optimistic
|
||||
forEachAccountFolder(routings) { params, folder, group ->
|
||||
if (folder != destFolderFullName) {
|
||||
imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, destFolderFullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result<String> = runCatching {
|
||||
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val context = imapClient.fetchForReply(params, routing.folder, uidOf(messageId))
|
||||
val content = ReplyBuilder.build(context, mode, account.email)
|
||||
// Bake the sending account's default signature into the reply/forward body — above the quoted
|
||||
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
|
||||
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
|
||||
val settings = accountSettingsRepository.get(routing.accountId)
|
||||
val sig = if (settings.signatureEnabled) {
|
||||
SignatureBlock.of(signatureRepository.getDefault(routing.accountId))
|
||||
} else {
|
||||
SignatureBlock.EMPTY
|
||||
}
|
||||
val draftId = UUID.randomUUID().toString()
|
||||
saveDraft(
|
||||
Draft(
|
||||
id = draftId,
|
||||
accountId = routing.accountId,
|
||||
to = content.to,
|
||||
cc = content.cc,
|
||||
subject = content.subject,
|
||||
body = sig.plain + content.body,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
bodyHtml = sig.html + content.bodyHtml,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
draftId
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves messages to each account's folder for [role], resolving the destination per account so the
|
||||
* unified inbox works. Removes local rows first (optimistic; reconciled on the next sync). When the
|
||||
* role folder is missing: trash falls back to a permanent expunge ([fallbackExpunge]); others fail.
|
||||
*/
|
||||
private suspend fun moveByRole(ids: List<String>, role: FolderRole, fallbackExpunge: Boolean): Result<Unit> =
|
||||
runCatching {
|
||||
val routings = messageDao.getRoutingByIds(ids)
|
||||
messageDao.deleteByIds(ids) // optimistic
|
||||
val destByAccount = routings.map { it.accountId }.distinct()
|
||||
.associateWith { resolveRoleFolder(it, role) }
|
||||
forEachAccountFolder(routings) { params, folder, group ->
|
||||
when (val dest = destByAccount[group.first().accountId]) {
|
||||
null ->
|
||||
if (fallbackExpunge) {
|
||||
group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) }
|
||||
} else {
|
||||
error("No ${role.name.lowercase()} folder for this account")
|
||||
}
|
||||
else -> imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups [routings] by account then source folder and runs [block] once per (account, folder) group. */
|
||||
private suspend fun forEachAccountFolder(
|
||||
routings: List<MessageRouting>,
|
||||
block: suspend (params: ImapConnectionParams, folder: String, group: List<MessageRouting>) -> Unit,
|
||||
) {
|
||||
routings.groupBy { it.accountId }.forEach { (accountId, accountMessages) ->
|
||||
val account = accountDao.getById(accountId)?.toDomain() ?: return@forEach
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
accountMessages.groupBy { it.folder }.forEach { (folder, group) -> block(params, folder, group) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the full name of an account's folder for [role], refreshing the cache once if needed.
|
||||
* Among same-role selectable folders (e.g. `[Gmail]/Spam` via RFC 6154 `\Junk` plus a user label
|
||||
* "Spam" matched by name), the server-advertised special-use folder wins regardless of LIST order,
|
||||
* so mail reaches the provider's built-in mailbox; absent one, the earliest LISTed folder is kept
|
||||
* (`maxByOrNull` returns the first max).
|
||||
*/
|
||||
private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? {
|
||||
fun pick(folders: List<FolderEntity>) =
|
||||
folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName
|
||||
pick(folderDao.getForAccountOnce(accountId))?.let { return it }
|
||||
// The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry.
|
||||
runCatching { refreshFolders(accountId) }
|
||||
return pick(folderDao.getForAccountOnce(accountId))
|
||||
}
|
||||
|
||||
/** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */
|
||||
@@ -104,9 +400,14 @@ class MailRepositoryImpl @Inject constructor(
|
||||
accountId = outgoing.accountId,
|
||||
toAddresses = outgoing.to,
|
||||
ccAddresses = outgoing.cc,
|
||||
bccAddresses = outgoing.bcc,
|
||||
subject = outgoing.subject,
|
||||
body = outgoing.body,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
bodyHtml = outgoing.bodyHtml,
|
||||
// Persist the cid↔file pairing (in staging-index order) so the send worker can attach
|
||||
// an inline image with its Content-ID even though the files are looked up by index.
|
||||
attachments = outgoing.attachments.toOutgoingAttachmentsJson(),
|
||||
),
|
||||
)
|
||||
sendScheduler.sendNow()
|
||||
@@ -130,8 +431,7 @@ class MailRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeDrafts(): Flow<List<Draft>> =
|
||||
draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
override fun observeDrafts(): Flow<List<Draft>> = draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
override suspend fun getDraft(id: String): Draft? = draftDao.getById(id)?.toDomain()
|
||||
|
||||
@@ -139,8 +439,9 @@ class MailRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun deleteDraft(id: String) = draftDao.delete(id)
|
||||
|
||||
override fun observeOutbox(): Flow<List<OutboxMessage>> =
|
||||
outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
override fun observeOutbox(): Flow<List<OutboxMessage>> = outboxDao.observeAll().map { rows ->
|
||||
rows.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun cancelOutboxMessage(id: String) {
|
||||
outboxDao.delete(id)
|
||||
@@ -149,49 +450,55 @@ class MailRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun retryOutbox() = sendScheduler.sendNow()
|
||||
|
||||
override suspend fun searchServer(query: String) {
|
||||
accountDao.getAll().forEach { entity ->
|
||||
val account = entity.toDomain()
|
||||
runCatching {
|
||||
val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT)
|
||||
// Mark hits as non-inbox so they show only while searching (and never overwrite the
|
||||
// inbox membership of a row that is genuinely in the inbox).
|
||||
val entities = results.map { it.toEntity(account.id, inInbox = false) }
|
||||
messageDao.insertNew(entities)
|
||||
entities.forEach {
|
||||
messageDao.updateHeaderContent(
|
||||
id = it.id,
|
||||
sender = it.sender,
|
||||
senderEmail = it.senderEmail,
|
||||
subject = it.subject,
|
||||
timestampMillis = it.timestampMillis,
|
||||
)
|
||||
override suspend fun searchServer(query: String, accountId: String?, folder: String) {
|
||||
accountDao.getAll()
|
||||
.filter { accountId == null || it.id == accountId }
|
||||
.forEach { entity ->
|
||||
val account = entity.toDomain()
|
||||
runCatching {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val results = imapClient.search(params, folder, query, SEARCH_LIMIT)
|
||||
// Mark hits as search-only (inInbox = false) so they show only while searching, and
|
||||
// never overwrite the synced membership of a row that is genuinely in the folder.
|
||||
val entities = results.map { it.toEntity(account.id, folder, inInbox = false) }
|
||||
messageDao.insertNew(entities)
|
||||
entities.forEach {
|
||||
messageDao.updateHeaderContent(
|
||||
id = it.id,
|
||||
sender = it.sender,
|
||||
senderEmail = it.senderEmail,
|
||||
subject = it.subject,
|
||||
timestampMillis = it.timestampMillis,
|
||||
uid = it.uid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearSearchResults() = messageDao.deleteSearchRows()
|
||||
|
||||
/** Writes downloaded bytes to a private cache file that the FileProvider can share. */
|
||||
private fun saveToCache(attachment: DownloadedAttachment): File {
|
||||
val dir = File(context.cacheDir, "attachments").apply { mkdirs() }
|
||||
val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
|
||||
return File(dir, safeName).also { file ->
|
||||
file.outputStream().use { it.write(attachment.bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun accountFor(id: String): Account? {
|
||||
val entity = messageDao.getById(id) ?: return null
|
||||
return accountDao.getById(entity.accountId)?.toDomain()
|
||||
/**
|
||||
* Deterministic cache path for one attachment part, under the FileProvider-shared `attachments/`
|
||||
* dir. Keying by message id + part index lets prefetch and on-demand download share the same file
|
||||
* and avoids filename collisions between messages.
|
||||
*/
|
||||
private fun attachmentFile(messageId: String, partIndex: Int, filename: String): File {
|
||||
val safeName = filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
|
||||
return File(attachmentCacheDir(context.cacheDir, messageId), "$partIndex/$safeName")
|
||||
}
|
||||
}
|
||||
|
||||
private const val SEARCH_LIMIT = 50
|
||||
|
||||
/** Rows per page for the unified inbox (issue #124) — a page is a few screenfuls of message rows. */
|
||||
private const val MAILBOX_PAGE_SIZE = 40
|
||||
|
||||
/** Attempts for the background best-effort SEEN-flag push before giving up silently (issue #148). */
|
||||
private const val SEEN_FLAG_PUSH_MAX_ATTEMPTS = 3
|
||||
|
||||
/** Base backoff between SEEN-flag push retries, scaled by attempt number (2s, then 4s). */
|
||||
private const val SEEN_FLAG_RETRY_BACKOFF_MS = 2_000L
|
||||
|
||||
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
|
||||
private fun uidOf(id: String): String = id.substringAfterLast(':')
|
||||
|
||||
private fun snippetOf(body: String): String =
|
||||
body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(140)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user