diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 48ba783e..2f43d35b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,20 +1,17 @@ name: Release — build & publish on: - pull_request: - types: [closed] - branches: [master] - paths-ignore: - - '**/*.md' - - 'docs/**' + push: + tags: + - 'v*' jobs: release: name: Build signed APK/AAB & publish - if: github.event.pull_request.merged == true runs-on: ubuntu-latest permissions: contents: write + id-token: write steps: - name: Checkout @@ -22,6 +19,14 @@ jobs: with: fetch-depth: 0 + - name: Validate release tag + run: | + git fetch origin master --no-tags + git merge-base --is-ancestor "$GITHUB_SHA" origin/master + VERSION=$(sed -nE 's/^def appVersionName = "([^"]+)"$/\1/p' app/build.gradle) + test -n "$VERSION" + test "$GITHUB_REF_NAME" = "v$VERSION" + - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -49,12 +54,19 @@ jobs: - name: Make gradlew executable run: chmod +x ./gradlew - - name: Extract version - id: version + - name: Decode google-services.json + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} run: | - CODE=$(grep -oP '(?<=def appVersionCode = )\d+' app/build.gradle | head -n1) - echo "code=$CODE" >> $GITHUB_OUTPUT - echo "name=2.6.$CODE" >> $GITHUB_OUTPUT + # Kept out of the repository and injected here. A fork or a pull request from a fork + # gets no secrets, and the build is deliberately able to run without the file: sign-in + # and Drive sync are simply disabled in that case. + if [ -n "$GOOGLE_SERVICES_JSON" ]; then + echo "$GOOGLE_SERVICES_JSON" | base64 -d > app/google-services.json + echo "google-services.json restored from the repository secret." + else + echo "GOOGLE_SERVICES_JSON is not available; building without Firebase." + fi - name: Decode keystore run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/release.jks @@ -71,49 +83,102 @@ jobs: run: | mkdir -p artifacts cp app/build/outputs/apk/release/*.apk \ - "artifacts/MyNotes-v${{ steps.version.outputs.name }}.apk" + "artifacts/MyNotes-${{ github.ref_name }}.apk" cp app/build/outputs/bundle/release/*.aab \ - "artifacts/MyNotes-v${{ steps.version.outputs.name }}.aab" + "artifacts/MyNotes-${{ github.ref_name }}.aab" - - name: Generate release notes from commits + - name: Generate English release notes from changelog id: notes run: | - PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - RANGE=${PREV:+"$PREV..HEAD"} + VERSION="${GITHUB_REF_NAME#v}" + awk -v version="$VERSION" ' + $0 == "## [" version "]" || index($0, "## [" version "] -") == 1 { in_version = 1; next } + in_version && /^## \[/ { exit } + in_version { print } + ' CHANGELOG.md > release-notes.md + test -s release-notes.md + + mkdir -p distribution/whatsnew + python3 - <<'PY' + from pathlib import Path + import re + + sections = [] + section = None + bullet = None + for raw_line in Path("release-notes.md").read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + heading = re.fullmatch(r"\*\*(.+?)\*\*", line) + if heading: + section = heading.group(1) + continue + if line.startswith("- "): + bullet = [line[2:]] + sections.append((section, bullet)) + elif bullet is not None: + bullet.append(line) + + def shorten(text, limit=140): + text = re.sub(r"\*\*(.*?)\*\*", r"\1", " ".join(text)) + text = re.sub(r"\s+", " ", text).strip() + if len(text) <= limit: + return text + return text[: limit - 1].rsplit(" ", 1)[0] + "…" + + seen_sections = set() + highlights = [] + for category, lines in sections: + category = category or "Updates" + if category in seen_sections: + continue + seen_sections.add(category) + highlights.append(f"• {category} — {shorten(lines)}") + + selected = [] + for highlight in highlights: + candidate = "\n".join(selected + [highlight]) + if len(candidate) > 500: + break + selected.append(highlight) + Path("distribution/whatsnew/whatsnew-en-US").write_text( + "\n".join(selected), encoding="utf-8" + ) + PY + { echo 'notes<> "$GITHUB_OUTPUT" + - name: Authenticate to Google Cloud + id: google-auth + uses: google-github-actions/auth@v3 + with: + project_id: ${{ vars.GOOGLE_CLOUD_PROJECT_ID }} + workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GOOGLE_PLAY_SERVICE_ACCOUNT_EMAIL }} + create_credentials_file: true + + - name: Publish AAB to Google Play closed testing + uses: r0adkll/upload-google-play@v1.1.5 + with: + serviceAccountJson: ${{ steps.google-auth.outputs.credentials_file_path }} + packageName: com.pasich.mynotes + releaseFiles: artifacts/MyNotes-${{ github.ref_name }}.aab + tracks: ${{ vars.GOOGLE_PLAY_CLOSED_TRACK }} + status: completed + whatsNewDirectory: distribution/whatsnew + - name: Create GitHub Release uses: ncipollo/release-action@v1 with: - tag: v${{ steps.version.outputs.name }} - name: "MyNotes v${{ steps.version.outputs.name }}" + tag: ${{ github.ref_name }} + name: "MyNotes ${{ github.ref_name }}" body: ${{ steps.notes.outputs.notes }} artifacts: "artifacts/*" artifactErrorsFailBuild: true makeLatest: true token: ${{ secrets.GITHUB_TOKEN }} - - # - name: Generate Play Store release notes - # run: | - # mkdir -p distribution/whatsnew - # PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - # RANGE=${PREV:+"$PREV..HEAD"} - # git log ${RANGE} --no-merges --format='• %s' \ - # | head -20 \ - # | head -c 490 \ - # > distribution/whatsnew/whatsnew-en-US - - # - name: Publish to Google Play (Open Testing) - # uses: r0adkll/upload-google-play@v3 - # with: - # serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - # packageName: com.pasich.mynotes - # releaseFiles: artifacts/MyNotes-v${{ steps.version.outputs.name }}.aab - # track: beta - # status: completed - # whatsNewDirectory: distribution/whatsnew diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e164cc43..150af5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,54 @@ -name: CI — build check +name: CI — build and quality checks on: pull_request: types: [opened, synchronize, reopened] jobs: + editor: + name: Build notes editor and audit dependencies + runs-on: ubuntu-latest + + defaults: + run: + working-directory: notes_editor + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: notes_editor/package-lock.json + + - name: Install editor dependencies + run: npm ci + + - name: Build editor bundle + run: npm run build + + - name: Audit dependencies + run: npm audit --audit-level=high + build: - name: Build debug & run tests + name: Android build, tests, lint, and formatting runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' - name: Cache Gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -28,20 +56,51 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-gradle- - - name: Accept SDK licenses & install platform + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + with: + log-accepted-android-sdk-licenses: 'false' + + - name: Install Android platform run: | - yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 - $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + sdkmanager \ --channel=3 \ - "platforms;android-37" \ - "build-tools;35.0.0" \ - > /dev/null 2>&1 || true + "platforms;android-37.2" \ + "build-tools;35.0.0" - name: Make gradlew executable run: chmod +x ./gradlew + - name: Decode google-services.json + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + # Kept out of the repository and injected here. A fork or a pull request from a fork + # gets no secrets, and the build is deliberately able to run without the file: sign-in + # and Drive sync are simply disabled in that case. + if [ -n "$GOOGLE_SERVICES_JSON" ]; then + echo "$GOOGLE_SERVICES_JSON" | base64 -d > app/google-services.json + echo "google-services.json restored from the repository secret." + else + echo "GOOGLE_SERVICES_JSON is not available; building without Firebase." + fi + - name: Build debug - run: ./gradlew assembleDebug --no-daemon + run: ./gradlew :app:assembleDebug --no-daemon --stacktrace + + - name: Run unit tests and generate coverage + run: ./gradlew :app:testDebugUnitTest :app:createDebugUnitTestCoverageReport --no-daemon --stacktrace + + - name: Upload debug unit-test coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: debug-unit-test-coverage + path: app/build/reports/coverage/ + if-no-files-found: error + + - name: Run lint + run: ./gradlew :app:lintDebug --no-daemon --stacktrace - - name: Run unit tests - run: ./gradlew test --no-daemon + - name: Check Java formatting + run: ./gradlew :app:spotlessCheck --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore index 0c0c9e68..d30974a5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ local.properties ## Changelog.md app/src/main/res/raw/* !app/src/main/res/raw/keep.xml +# Firebase project configuration (local/variant-specific) +app/google-services.json +gha-creds-*.json + +# Local design/plan notes, deliberately kept out of the repository +docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c9725f2a..475288e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # CHANGELOG +## [2.6.48] - 01.09.2026 + +**Improvements** + +- **Safer Google Drive sync:** Sync snapshots are now published as immutable files and merged + deterministically, so a concurrent device update cannot overwrite another device's data. +- **Efficient background sync:** Periodic sync now runs only on unmetered networks when the battery + is not low, and unchanged data no longer creates an extra Drive snapshot. +- **Quality visibility:** Added JaCoCo unit-test coverage reports to CI for every pull request. + +**Fixes** + +- Sync now requires explicit first-sync confirmation in the coordinator itself, preventing any + caller from bypassing the data-upload review. +- Fixed updates to an existing Drive sync bundle on Android/JDK configurations that reject HTTP + PATCH requests. + +## [2.6.47] - 01.09.2026 + +**New** + +- **Google Drive sync:** Optionally keep notes, tasks, tags, preferences, and attachments in sync + across devices while continuing to work offline. Your data is merged safely before a sync is + published, and the first sync clearly explains what may be uploaded. +- **Your data:** Added an Account tab with Google sign-in, sync status, a manual sync action, and + an optional background-sync switch. Backup, export, and import remain available in their own + tabs. + +**Improvements** + +- Attachments are deduplicated and verified during sync, reducing unnecessary uploads while + protecting file integrity. +- The app now remains fully usable when Google services are unavailable or when you choose not to + sign in. +- Updated translations across all supported languages for the new sync and account experience. + +**Fixes** + +- Fixed several sync stability issues, including leaving the screen during an active sync and + preserving the time of the last successful sync. +- Fixed Google sign-in compatibility on Android 8.0 and 8.1. + ## [2.6.46] - 18.05.2026 **New** diff --git a/README.md b/README.md index f7266837..bb839688 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # My Notes -**My Notes** is a fast, clean, and fully private note-taking app for Android. -No accounts, no cloud, no ads — just your content stored safely on your device. +**My Notes** is a fast, clean, local-first note-taking app for Android. +No ads or tracking — your content stays on your device unless you explicitly enable Google Drive sync. --- @@ -39,15 +39,18 @@ No accounts, no cloud, no ads — just your content stored safely on your device - 🎨 **Themes and colors** to match your mood - 🌍 **10 languages** — EN, UK, RU, DE, FR, ES, IT, PL, BE, KK - 🚫 **No ads** — just your notes -- 🔒 **Fully offline** — no servers, no cloud +- 🔒 **Local-first** — works fully offline; Google Drive sync is optional - 💻 **Open-source** — transparent and accessible code - 🎯 **Modern and intuitive design** ## Privacy -My Notes does not collect or send any data to external servers. -All notes, attachments, and preferences are stored **locally on the device**. -No tracking, no analytics, no cloud — full privacy by design. +My Notes does not include advertising, analytics, or tracking. By default, notes, attachments, +and preferences remain **locally on the device**. Google Drive sync is optional and starts only +after you sign in, review the first-sync estimate, and confirm it. When enabled, the selected +notes, attachments, tasks, tags, categories, and preferences are transferred to the Google Drive +of the account you chose, solely to provide sync. Disabling sync leaves the local data available +on the device. ## Tech Stack diff --git a/app/build.gradle b/app/build.gradle index 1982465f..78756569 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,13 +4,24 @@ plugins { id 'com.diffplug.spotless' } +// app/google-services.json is gitignored, so a fresh clone or a CI job does not have it. The +// google-services plugin aborts the build when the file is missing, so it is applied only when +// the configuration is actually present. +def googleServicesConfig = file("google-services.json") +def hasGoogleServices = googleServicesConfig.exists() +if (hasGoogleServices) { + apply plugin: 'com.google.gms.google-services' +} else { + logger.warn("google-services.json not found: Google sign-in and Drive sync are disabled in this build.") +} + apply plugin: 'com.android.application' apply plugin: 'com.google.dagger.hilt.android' apply from: "$projectDir/gradle/libs-task.gradle" apply from: "$projectDir/gradle/changelog-task.gradle" -def appVersionCode = 46 +def appVersionCode = 48 def appVersionName = "2.6.${appVersionCode}" def gitCommitHashProvider = providers.exec { @@ -26,6 +37,12 @@ preBuild.dependsOn(copyChangelog) android { compileSdkVersion 37 + sourceSets { + androidTest { + // MigrationTestHelper loads exported Room schemas from test APK assets. + assets.srcDirs += "$projectDir/schemas" + } + } buildFeatures { viewBinding = true dataBinding = true @@ -65,6 +82,12 @@ android { } resValue "string", "whatNewVersion", "\"🔥 v${versionName}\"" buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitCommitHashProvider.get()}\"" + + // Normally generated by the google-services plugin. The empty fallback keeps a build + // without google-services.json compilable; sign-in then reports that it is unavailable. + if (!hasGoogleServices) { + resValue "string", "default_web_client_id", "" + } } signingConfigs { @@ -91,6 +114,7 @@ android { debuggable true minifyEnabled false shrinkResources false + enableUnitTestCoverage true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } @@ -138,16 +162,25 @@ gradle.taskGraph.whenReady { graph -> ext { markwon_version = '4.6.2' - roomVersion = '2.8.1' + roomVersion = '2.8.4' } dependencies { + implementation platform('com.google.firebase:firebase-bom:34.18.0') + implementation 'com.google.firebase:firebase-auth' implementation 'androidx.appcompat:appcompat:1.7.1' - implementation 'com.google.android.material:material:1.13.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation 'com.google.android.material:material:1.14.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.2' implementation 'androidx.preference:preference:1.2.1' - implementation "androidx.lifecycle:lifecycle-viewmodel:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel:2.11.0" implementation 'androidx.core:core-splashscreen:1.2.0' implementation 'androidx.activity:activity:1.13.0' + implementation 'androidx.work:work-runtime:2.10.1' + + // Google Credential Manager (authentication only; Drive authorization is requested separately) + implementation 'androidx.credentials:credentials:1.5.0' + implementation 'androidx.credentials:credentials-play-services-auth:1.5.0' + implementation 'com.google.android.libraries.identity.googleid:googleid:1.2.0' + implementation 'com.google.android.gms:play-services-auth:21.4.0' //DI (Hilt) implementation "com.google.dagger:hilt-android:$daggerHilt" @@ -166,7 +199,7 @@ dependencies { implementation 'com.android.billingclient:billing:8.3.0' //Other lib (Gson/PhotoView) - implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.google.code.gson:gson:2.14.0' implementation 'com.github.chrisbanes:PhotoView:2.3.0' implementation 'androidx.exifinterface:exifinterface:1.4.2' @@ -191,6 +224,17 @@ dependencies { } +// Room 2.8.x migration tooling mixes serialization 1.7.x BOM constraints with its +// 1.8.x generated serializers. Keep the runtime family aligned or Android migration +// tests fail with AbstractMethodError at runtime. +configurations.configureEach { + resolutionStrategy.force( + 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1', + 'org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1', + 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1', + 'org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1') +} + spotless { java { target 'src/**/*.java' @@ -200,3 +244,13 @@ spotless { endWithNewline() } } + +tasks.named("spotlessJava").configure { + dependsOn generateLibsJson + dependsOn copyChangelog +} + +tasks.named("spotlessJavaCheck").configure { + dependsOn generateLibsJson + dependsOn copyChangelog +} diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/14.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/14.json new file mode 100644 index 00000000..85b90c1c --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/14.json @@ -0,0 +1,318 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "5302674ea84603d54d262bcb5d4b64b2", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `systemAction` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemAction", + "columnName": "systemAction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `value` TEXT, `date` INTEGER NOT NULL, `tag` TEXT, `valueJson` TEXT, `hasRichContent` INTEGER NOT NULL, `attachments` TEXT, `isTrash` INTEGER NOT NULL, `reminderTime` INTEGER, `isPinned` INTEGER NOT NULL, `reminderRepeat` TEXT NOT NULL, `reminderIntervalMinutes` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "valueJson", + "columnName": "valueJson", + "affinity": "TEXT" + }, + { + "fieldPath": "hasRichContent", + "columnName": "hasRichContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isDone` INTEGER NOT NULL DEFAULT 0, `categoryId` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0, `reminderTime` INTEGER, `reminderIntervalMinutes` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT NOT NULL DEFAULT '#6750A4', `position` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'#6750A4'" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordType` TEXT NOT NULL, `localId` INTEGER NOT NULL, `stableId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `deletedAt` INTEGER, PRIMARY KEY(`recordType`, `localId`))", + "fields": [ + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordType", + "localId" + ] + }, + "indices": [ + { + "name": "index_sync_metadata_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_metadata_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_metadata_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_sync_metadata_deletedAt", + "unique": false, + "columnNames": [ + "deletedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" + } + ] + } + ], + "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, '5302674ea84603d54d262bcb5d4b64b2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/15.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/15.json new file mode 100644 index 00000000..36e4c1a0 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/15.json @@ -0,0 +1,318 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "5302674ea84603d54d262bcb5d4b64b2", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `systemAction` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemAction", + "columnName": "systemAction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `value` TEXT, `date` INTEGER NOT NULL, `tag` TEXT, `valueJson` TEXT, `hasRichContent` INTEGER NOT NULL, `attachments` TEXT, `isTrash` INTEGER NOT NULL, `reminderTime` INTEGER, `isPinned` INTEGER NOT NULL, `reminderRepeat` TEXT NOT NULL, `reminderIntervalMinutes` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "valueJson", + "columnName": "valueJson", + "affinity": "TEXT" + }, + { + "fieldPath": "hasRichContent", + "columnName": "hasRichContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isDone` INTEGER NOT NULL DEFAULT 0, `categoryId` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0, `reminderTime` INTEGER, `reminderIntervalMinutes` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT NOT NULL DEFAULT '#6750A4', `position` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'#6750A4'" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordType` TEXT NOT NULL, `localId` INTEGER NOT NULL, `stableId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `deletedAt` INTEGER, PRIMARY KEY(`recordType`, `localId`))", + "fields": [ + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordType", + "localId" + ] + }, + "indices": [ + { + "name": "index_sync_metadata_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_metadata_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_metadata_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_sync_metadata_deletedAt", + "unique": false, + "columnNames": [ + "deletedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" + } + ] + } + ], + "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, '5302674ea84603d54d262bcb5d4b64b2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/16.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/16.json new file mode 100644 index 00000000..a3ffca0f --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/16.json @@ -0,0 +1,444 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "fd69b443b7fa86a0fb922f0ce809f0cb", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `systemAction` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemAction", + "columnName": "systemAction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `value` TEXT, `date` INTEGER NOT NULL, `tag` TEXT, `valueJson` TEXT, `hasRichContent` INTEGER NOT NULL, `attachments` TEXT, `isTrash` INTEGER NOT NULL, `reminderTime` INTEGER, `isPinned` INTEGER NOT NULL, `reminderRepeat` TEXT NOT NULL, `reminderIntervalMinutes` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "valueJson", + "columnName": "valueJson", + "affinity": "TEXT" + }, + { + "fieldPath": "hasRichContent", + "columnName": "hasRichContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isDone` INTEGER NOT NULL DEFAULT 0, `categoryId` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0, `reminderTime` INTEGER, `reminderIntervalMinutes` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT NOT NULL DEFAULT '#6750A4', `position` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'#6750A4'" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordType` TEXT NOT NULL, `localId` INTEGER NOT NULL, `stableId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `deletedAt` INTEGER, PRIMARY KEY(`recordType`, `localId`))", + "fields": [ + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordType", + "localId" + ] + }, + "indices": [ + { + "name": "index_sync_metadata_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_metadata_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_metadata_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_sync_metadata_deletedAt", + "unique": false, + "columnNames": [ + "deletedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" + } + ] + }, + { + "tableName": "sync_conflicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recordType` TEXT NOT NULL, `stableId` TEXT NOT NULL, `winnerSource` TEXT NOT NULL, `winnerJson` TEXT NOT NULL, `loserJson` TEXT NOT NULL, `winnerUpdatedAt` INTEGER NOT NULL, `loserUpdatedAt` INTEGER NOT NULL, `winnerTombstone` INTEGER NOT NULL, `loserTombstone` INTEGER NOT NULL, `resolution` TEXT NOT NULL, `resolved` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `resolvedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerSource", + "columnName": "winnerSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerJson", + "columnName": "winnerJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loserJson", + "columnName": "loserJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerUpdatedAt", + "columnName": "winnerUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loserUpdatedAt", + "columnName": "loserUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "winnerTombstone", + "columnName": "winnerTombstone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loserTombstone", + "columnName": "loserTombstone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolution", + "columnName": "resolution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resolved", + "columnName": "resolved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolvedAt", + "columnName": "resolvedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_conflicts_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_conflicts_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_conflicts_resolved", + "unique": false, + "columnNames": [ + "resolved" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_conflicts_resolved` ON `${TABLE_NAME}` (`resolved`)" + }, + { + "name": "index_sync_conflicts_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_conflicts_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + } + ], + "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, 'fd69b443b7fa86a0fb922f0ce809f0cb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/17.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/17.json new file mode 100644 index 00000000..943a2182 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/17.json @@ -0,0 +1,494 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "a4d32b726841266ad1177024b319e333", + "entities": [ + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `systemAction` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemAction", + "columnName": "systemAction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `value` TEXT, `date` INTEGER NOT NULL, `tag` TEXT, `valueJson` TEXT, `hasRichContent` INTEGER NOT NULL, `attachments` TEXT, `isTrash` INTEGER NOT NULL, `reminderTime` INTEGER, `isPinned` INTEGER NOT NULL, `reminderRepeat` TEXT NOT NULL, `reminderIntervalMinutes` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "valueJson", + "columnName": "valueJson", + "affinity": "TEXT" + }, + { + "fieldPath": "hasRichContent", + "columnName": "hasRichContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isDone` INTEGER NOT NULL DEFAULT 0, `categoryId` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0, `reminderTime` INTEGER, `reminderIntervalMinutes` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "reminderTime", + "columnName": "reminderTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderIntervalMinutes", + "columnName": "reminderIntervalMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT NOT NULL DEFAULT '#6750A4', `position` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'#6750A4'" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordType` TEXT NOT NULL, `localId` INTEGER NOT NULL, `stableId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `deletedAt` INTEGER, PRIMARY KEY(`recordType`, `localId`))", + "fields": [ + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordType", + "localId" + ] + }, + "indices": [ + { + "name": "index_sync_metadata_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_metadata_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_metadata_updatedAt", + "unique": false, + "columnNames": [ + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)" + }, + { + "name": "index_sync_metadata_deletedAt", + "unique": false, + "columnNames": [ + "deletedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_metadata_deletedAt` ON `${TABLE_NAME}` (`deletedAt`)" + } + ] + }, + { + "tableName": "sync_conflicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recordType` TEXT NOT NULL, `stableId` TEXT NOT NULL, `winnerSource` TEXT NOT NULL, `winnerJson` TEXT NOT NULL, `loserJson` TEXT NOT NULL, `winnerUpdatedAt` INTEGER NOT NULL, `loserUpdatedAt` INTEGER NOT NULL, `winnerTombstone` INTEGER NOT NULL, `loserTombstone` INTEGER NOT NULL, `resolution` TEXT NOT NULL, `resolved` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `resolvedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stableId", + "columnName": "stableId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerSource", + "columnName": "winnerSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerJson", + "columnName": "winnerJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loserJson", + "columnName": "loserJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "winnerUpdatedAt", + "columnName": "winnerUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loserUpdatedAt", + "columnName": "loserUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "winnerTombstone", + "columnName": "winnerTombstone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loserTombstone", + "columnName": "loserTombstone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolution", + "columnName": "resolution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resolved", + "columnName": "resolved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resolvedAt", + "columnName": "resolvedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_conflicts_recordType_stableId", + "unique": true, + "columnNames": [ + "recordType", + "stableId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_conflicts_recordType_stableId` ON `${TABLE_NAME}` (`recordType`, `stableId`)" + }, + { + "name": "index_sync_conflicts_resolved", + "unique": false, + "columnNames": [ + "resolved" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_conflicts_resolved` ON `${TABLE_NAME}` (`resolved`)" + }, + { + "name": "index_sync_conflicts_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_conflicts_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `status` TEXT NOT NULL, `backendIdentifier` TEXT, `lastSuccessfulSyncAt` INTEGER, `attemptStartedAt` INTEGER, `errorMessage` TEXT, `conflictCount` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backendIdentifier", + "columnName": "backendIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSuccessfulSyncAt", + "columnName": "lastSuccessfulSyncAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptStartedAt", + "columnName": "attemptStartedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "conflictCount", + "columnName": "conflictCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "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, 'a4d32b726841266ad1177024b319e333')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java index d64a1c52..ffbfdb5c 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java @@ -1,11 +1,14 @@ package com.pasich.mynotes.db; +import static com.google.common.truth.Truth.assertThat; + import androidx.room.testing.MigrationTestHelper; import androidx.sqlite.db.SupportSQLiteDatabase; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; import com.pasich.mynotes.data.database.AppDatabase; import java.io.IOException; +import java.util.regex.Pattern; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -21,52 +24,93 @@ public class MigrationTest { InstrumentationRegistry.getInstrumentation(), AppDatabase.class); @Test - public void migrate2to3_succeeds() throws IOException { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); + public void migrate13to14_backfillsStableIdsAndTimestamps() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 13); + db.execSQL( + "INSERT INTO notes " + + "(id, title, value, date, tag, valueJson, hasRichContent, attachments, isTrash, " + + "reminderTime, isPinned, reminderRepeat, reminderIntervalMinutes) " + + "VALUES (101, 'Note', 'Body', 10, '', '', 0, '', 0, NULL, 0, 'NONE', 0)"); + db.execSQL( + "INSERT INTO tasks " + + "(id, title, description, isDone, categoryId, createdAt, position, reminderTime, " + + "reminderIntervalMinutes) " + + "VALUES (102, 'Task', NULL, 0, 0, 10, 0, NULL, 0)"); + db.execSQL( + "INSERT INTO tags (id, name, visibility, systemAction, position) " + + "VALUES (103, 'Tag', 0, 0, 0)"); db.close(); - helper.runMigrationsAndValidate(TEST_DB, 3, true, AppDatabase.MIGRATION_2_3); - } - @Test - public void migrate3to4_addsPositionColumn() throws IOException { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 3); - db.close(); - helper.runMigrationsAndValidate(TEST_DB, 4, true, AppDatabase.MIGRATION_3_4); + SupportSQLiteDatabase migrated = + helper.runMigrationsAndValidate(TEST_DB, 14, true, AppDatabase.MIGRATION_13_14); + try (android.database.Cursor cursor = + migrated.query( + "SELECT recordType, localId, stableId, updatedAt, deletedAt " + + "FROM sync_metadata ORDER BY recordType")) { + assertThat(cursor.getCount()).isEqualTo(3); + Pattern uuid = + Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"); + while (cursor.moveToNext()) { + assertThat(cursor.getLong(cursor.getColumnIndexOrThrow("localId"))).isAtLeast(101L); + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("stableId"))) + .matches(uuid.pattern()); + assertThat(cursor.getLong(cursor.getColumnIndexOrThrow("updatedAt"))) + .isGreaterThan(0L); + assertThat(cursor.isNull(cursor.getColumnIndexOrThrow("deletedAt"))).isTrue(); + } + } finally { + migrated.close(); + } } @Test - public void migrate4to5_addsValueJsonAndRichContentColumns() throws IOException { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 4); + public void migrate15to16_createsConflictTable() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 15); db.close(); - helper.runMigrationsAndValidate(TEST_DB, 5, true, AppDatabase.MIGRATION_4_5); - } - @Test - public void migrate5to6_addsAttachmentsColumn() throws IOException { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 5); - db.close(); - helper.runMigrationsAndValidate(TEST_DB, 6, true, AppDatabase.MIGRATION_5_6); + SupportSQLiteDatabase migrated = + helper.runMigrationsAndValidate(TEST_DB, 16, true, AppDatabase.MIGRATION_15_16); + try (android.database.Cursor cursor = + migrated.query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sync_conflicts'")) { + assertThat(cursor.moveToFirst()).isTrue(); + assertThat(cursor.getString(0)).isEqualTo("sync_conflicts"); + } finally { + migrated.close(); + } } @Test - public void migrate6to7_addIsTrashColumn() throws IOException { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 6); + public void migrate16to17_createsSyncStateTable() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 16); db.close(); - helper.runMigrationsAndValidate(TEST_DB, 7, true, AppDatabase.MIGRATION_6_7); + + SupportSQLiteDatabase migrated = + helper.runMigrationsAndValidate(TEST_DB, 17, true, AppDatabase.MIGRATION_16_17); + try (android.database.Cursor cursor = + migrated.query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sync_state'")) { + assertThat(cursor.moveToFirst()).isTrue(); + assertThat(cursor.getString(0)).isEqualTo("sync_state"); + } finally { + migrated.close(); + } } @Test - public void migrateAllStepsChained_succeeds() throws IOException { - SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); + public void migrate14to15_backfillsCategoryMetadata() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 14); db.close(); - helper.runMigrationsAndValidate( - TEST_DB, - 7, - true, - AppDatabase.MIGRATION_2_3, - AppDatabase.MIGRATION_3_4, - AppDatabase.MIGRATION_4_5, - AppDatabase.MIGRATION_5_6, - AppDatabase.MIGRATION_6_7); + SupportSQLiteDatabase migrated = + helper.runMigrationsAndValidate(TEST_DB, 15, true, AppDatabase.MIGRATION_14_15); + try (android.database.Cursor cursor = + migrated.query( + "SELECT COUNT(*) FROM sync_metadata WHERE recordType = 'category'")) { + assertThat(cursor.moveToFirst()).isTrue(); + assertThat(cursor.getInt(0)).isEqualTo(0); + } finally { + migrated.close(); + } } } diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java index cb0a753e..d2766f61 100644 --- a/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java +++ b/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java @@ -145,7 +145,7 @@ public void addMultipleNotes_allAppearInList() { @Test public void updateNoteContent_doesNotOverwriteReminderFields() { Note note = makeNote("Title", "content"); - int id = (int) noteDao.addNote(note); + int id = noteDao.addNote(note).intValue(); note.setId(id); // Set reminder fields directly @@ -165,7 +165,7 @@ public void updateNoteContent_doesNotOverwriteReminderFields() { @Test public void clearReminderSync_nullifiesReminderFields() { Note note = makeNote("Reminder note", "body"); - int id = (int) noteDao.addNote(note); + int id = noteDao.addNote(note).intValue(); noteDao.updateReminderFullSync(id, System.currentTimeMillis() + 3_600_000L, "DAILY", 0); noteDao.clearReminderSync(id); @@ -179,7 +179,7 @@ public void clearReminderSync_nullifiesReminderFields() { @Test public void updateReminderFullSync_setsAllReminderFields() { Note note = makeNote("Interval note", "body"); - int id = (int) noteDao.addNote(note); + int id = noteDao.addNote(note).intValue(); long time = System.currentTimeMillis() + 600_000L; noteDao.updateReminderFullSync(id, time, "NONE", 10); @@ -194,8 +194,8 @@ public void updateReminderFullSync_setsAllReminderFields() { public void getNotesWithActiveReminders_excludesPastReminders() { Note future = makeNote("Future", ""); Note past = makeNote("Past", ""); - int futureId = (int) noteDao.addNote(future); - int pastId = (int) noteDao.addNote(past); + int futureId = noteDao.addNote(future).intValue(); + int pastId = noteDao.addNote(past).intValue(); long now = System.currentTimeMillis(); noteDao.updateReminderFullSync(futureId, now + 3_600_000L, "NONE", 0); noteDao.updateReminderFullSync(pastId, now - 3_600_000L, "DAILY", 0); diff --git a/app/src/main/assets/editor/editor.html b/app/src/main/assets/editor/editor.html index 629310ab..2b96d3fa 100644 --- a/app/src/main/assets/editor/editor.html +++ b/app/src/main/assets/editor/editor.html @@ -32,6 +32,7 @@ +