diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a7d02fca..088ec2ca 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,18 +1,18 @@ -name: CI/CD — build and publish release +name: Release — build & publish on: - pull_request: - branches: [ main ] + push: + branches: [master] jobs: - build-and-release: - name: Build APK & AAB and publish Release + release: + name: Build signed APK/AAB & publish runs-on: ubuntu-latest permissions: contents: write steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 17 @@ -27,73 +27,67 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - .gradle - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*','**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- - - name: Install Android SDK command-line tools and platforms + - name: Accept SDK licenses & install platform run: | - sudo apt-get update -y - sudo apt-get install -y --no-install-recommends wget unzip - mkdir -p $HOME/Android/Sdk/cmdline-tools - wget -q https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O /tmp/cmdline-tools.zip - unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools - mkdir -p $HOME/Android/Sdk/cmdline-tools/latest - mv /tmp/cmdline-tools/cmdline-tools/* $HOME/Android/Sdk/cmdline-tools/latest/ - export ANDROID_HOME=$HOME/Android/Sdk - export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH - yes | sdkmanager --licenses - sdkmanager "platform-tools" "platforms;android-33" "build-tools;33.0.2" "platforms;android-35" "build-tools;35.0.0" - shell: bash + yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + --channel=3 \ + "platforms;android-37" \ + "build-tools;35.0.0" \ + > /dev/null 2>&1 || true - - name: Ensure gradlew is executable + - name: Make gradlew executable run: chmod +x ./gradlew - - name: Extract versionName from Gradle - id: get_version + - name: Extract version + id: version run: | - VERSION=$(grep -Po "versionName\s+'\\K[^']+" app/build.gradle | head -n1 || true) - if [ -z "$VERSION" ]; then echo "Failed to extract versionName"; exit 1; fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + CODE=$(grep -oP '(?<=def appVersionCode = )\d+' app/build.gradle | head -n1) + echo "code=$CODE" >> $GITHUB_OUTPUT + echo "name=2.5.$CODE" >> $GITHUB_OUTPUT - - name: Build release APK and AAB - id: build_release + - name: Decode keystore + run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/release.jks + + - name: Build signed release APK & AAB + env: + KEYSTORE_PATH: /tmp/release.jks + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: ./gradlew assembleRelease bundleRelease --no-daemon + + - name: Collect artifacts + run: | + mkdir -p artifacts + cp app/build/outputs/apk/release/*.apk \ + "artifacts/MyNotes-v${{ steps.version.outputs.name }}.apk" + cp app/build/outputs/bundle/release/*.aab \ + "artifacts/MyNotes-v${{ steps.version.outputs.name }}.aab" + + - name: Generate release notes from commits + id: notes run: | - ./gradlew clean assembleRelease bundleRelease --no-daemon - mkdir -p release_artifacts - APK_PATH=$(ls app/build/outputs/apk/release/*.apk 2>/dev/null | head -n1 || true) - AAB_PATH=$(ls app/build/outputs/bundle/release/*.aab 2>/dev/null | head -n1 || true) - VERSION=${{ steps.get_version.outputs.version }} - if [ -n "$APK_PATH" ]; then cp "$APK_PATH" "release_artifacts/MyNote${VERSION}.apk"; fi - if [ -n "$AAB_PATH" ]; then cp "$AAB_PATH" "release_artifacts/MyNote${VERSION}.aab"; fi - # expose outputs for later steps - echo "apk_path=$( [ -f release_artifacts/MyNote${VERSION}.apk ] && echo release_artifacts/MyNote${VERSION}.apk || echo )" >> $GITHUB_OUTPUT - echo "aab_path=$( [ -f release_artifacts/MyNote${VERSION}.aab ] && echo release_artifacts/MyNote${VERSION}.aab || echo )" >> $GITHUB_OUTPUT - shell: bash + PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREV" ]; then + NOTES=$(git log "$PREV"..HEAD --pretty=format:"- %s" --no-merges) + else + NOTES=$(git log --pretty=format:"- %s" --no-merges | head -30) + fi + echo "notes<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT - - name: Create or update GitHub Release - id: create_release + - name: Create GitHub Release uses: ncipollo/release-action@v1 with: - tag: v${{ steps.get_version.outputs.version }} - name: MyNote${{ steps.get_version.outputs.version }} + tag: v${{ steps.version.outputs.name }} + name: "MyNotes v${{ steps.version.outputs.name }}" + body: ${{ steps.notes.outputs.notes }} + artifacts: "artifacts/*" + artifactErrorsFailBuild: true + makeLatest: true token: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload APK to Release - if: steps.build_release.outputs.apk_path != '' - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ${{ steps.build_release.outputs.apk_path }} - asset_name: MyNote${{ steps.get_version.outputs.version }}.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload AAB to Release - if: steps.build_release.outputs.aab_path != '' - uses: actions/upload-release-asset@v1 - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ${{ steps.build_release.outputs.aab_path }} - asset_name: MyNote${{ steps.get_version.outputs.version }}.aab - asset_content_type: application/octet-stream diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e164cc43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI — build check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build: + name: Build debug & run tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Accept SDK licenses & install platform + run: | + yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + --channel=3 \ + "platforms;android-37" \ + "build-tools;35.0.0" \ + > /dev/null 2>&1 || true + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Build debug + run: ./gradlew assembleDebug --no-daemon + + - name: Run unit tests + run: ./gradlew test --no-daemon diff --git a/CHANGELOG.md b/CHANGELOG.md index 45522e17..c83a2467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # CHANGELOG +## [2.5.45] - 10.05.2026 + +**New** + +- **Reminders:** You can now set a date and time reminder on any note. Tap the bell icon in the + note editor toolbar or open the note options menu to set, edit, or delete a reminder. When the + time comes, you'll receive a notification — tap it to open the note directly. Reminders support + repeat intervals (daily, weekly, monthly) and a snooze option (10 minutes, 1 hour, or tomorrow + morning). Reminders are restored automatically after a device reboot. +- **Tasks:** A new dedicated screen for managing tasks and to-dos. Create tasks with titles and + optional descriptions, organize them into color-coded categories, reorder by dragging, and mark + them as complete. Completed tasks are grouped separately and can be cleared in bulk. +- **Task reminders:** Set a date and time reminder on any active task — a notification arrives at + the chosen time and tapping it opens the task list directly. Only future times are selectable. + Reminders are restored automatically after a device reboot. + +**Fixes** + +- Fixed memory leaks that could occur during note editing and when navigating between screens. + +**Improvements** + +- **Pin notes:** You can now pin any note to keep it at the top of the list. Tap the note options + menu and select "Pin note" — pinned notes always appear first, regardless of the current sort + order. A small pin icon is displayed on pinned note cards. Tap "Unpin note" to remove the pin. +- Delete confirmation dialogs for tasks and categories now display a clear visual style + with an error-tinted icon and the item name. + ## [2.4.44] - 28.01.2026 - Fixed several issues reported in the previous version diff --git a/app/build.gradle b/app/build.gradle index 3e9ff631..da807fb2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ apply from: "$projectDir/gradle/changelog-task.gradle" def appVersionCode = 44 -def appVersionName = "2.4.${appVersionCode}" +def appVersionName = "2.5.${appVersionCode}" def gitCommitHashProvider = providers.exec { commandLine 'git', 'rev-parse', '--short', 'HEAD' @@ -24,7 +24,7 @@ preBuild.dependsOn(copyChangelog) android { - compileSdkVersion 36 + compileSdkVersion 37 buildFeatures { viewBinding = true dataBinding = true @@ -50,11 +50,13 @@ android { defaultConfig { applicationId "com.pasich.mynotes" minSdkVersion 26 - targetSdkVersion 36 + targetSdkVersion 37 versionCode appVersionCode versionName appVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + javaCompileOptions { annotationProcessorOptions { arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] @@ -64,12 +66,25 @@ android { buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitCommitHashProvider.get()}\"" } + signingConfigs { + release { + def ksPath = System.getenv("KEYSTORE_PATH") + if (ksPath) { + storeFile file(ksPath) + storePassword System.getenv("KEYSTORE_PASSWORD") + keyAlias System.getenv("KEY_ALIAS") + keyPassword System.getenv("KEY_PASSWORD") + } + } + } + buildTypes { release { debuggable false minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig System.getenv("KEYSTORE_PATH") ? signingConfigs.release : null } debug { debuggable true @@ -90,6 +105,24 @@ android { lint { abortOnError false } + testOptions { + unitTests { + returnDefaultValues = true + } + } +} + +// Fix for Windows PATH with embedded quotes breaking java.library.path in test JVM +// The Gradle worker JVM command is built by DefaultWorkerProcessBuilder which reads +// java.library.path from the daemon JVM. We sanitize it by replacing the system property. +def rawLibPath = System.getProperty("java.library.path") ?: "" +def cleanLibPath = rawLibPath.split(File.pathSeparator).findAll { !it.contains('"') }.join(File.pathSeparator) +System.setProperty("java.library.path", cleanLibPath) + +tasks.withType(Test).configureEach { + def jniDebug = "${project.projectDir}/src/testDebug/jniLibs" + def jniTest = "${project.projectDir}/src/test/jniLibs" + jvmArgs "-Djava.library.path=${jniDebug}${File.pathSeparator}${jniTest}" } // Only execute in the release build @@ -111,9 +144,9 @@ dependencies { implementation 'com.google.android.material:material:1.13.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.preference:preference:1.2.1' - implementation "androidx.lifecycle:lifecycle-viewmodel:2.9.4" - implementation 'androidx.core:core-splashscreen:1.0.1' - implementation 'androidx.activity:activity:1.11.0' + implementation "androidx.lifecycle:lifecycle-viewmodel:2.10.0" + implementation 'androidx.core:core-splashscreen:1.2.0' + implementation 'androidx.activity:activity:1.13.0' //DI (Hilt) implementation "com.google.dagger:hilt-android:$daggerHilt" @@ -129,17 +162,30 @@ dependencies { //Pay Billing implementation 'com.google.android.play:app-update:2.1.0' - implementation 'com.android.billingclient:billing:8.0.0' + implementation 'com.android.billingclient:billing:8.3.0' //Other lib (Gson/PhotoView) implementation 'com.google.code.gson:gson:2.13.2' implementation 'com.github.chrisbanes:PhotoView:2.3.0' - implementation 'androidx.exifinterface:exifinterface:1.4.1' + implementation 'androidx.exifinterface:exifinterface:1.4.2' //Markwon https://noties.io/Markwon/docs/v4/ implementation"io.noties.markwon:core:$markwon_version" implementation"io.noties.markwon:ext-strikethrough:$markwon_version" implementation"io.noties.markwon:linkify:$markwon_version" - + + // Unit tests + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.23.0' + testImplementation 'com.google.truth:truth:1.4.2' + + // Room testing (integration tests) + testImplementation "androidx.room:room-testing:$roomVersion" + androidTestImplementation 'androidx.test.ext:junit:1.3.0' + androidTestImplementation 'androidx.test:rules:1.7.0' + androidTestImplementation 'androidx.test:runner:1.7.0' + androidTestImplementation "androidx.room:room-testing:$roomVersion" + androidTestImplementation 'org.mockito:mockito-android:5.23.0' + androidTestImplementation 'com.google.truth:truth:1.4.5' } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9fd822fe..6a5b6322 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -80,7 +80,6 @@ # Keep line numbers and source file for debugging -keepattributes SourceFile,LineNumberTable --dontobfuscate # Keep all debug and logging related code -assumenosideeffects class android.util.Log { diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/10.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/10.json new file mode 100644 index 00000000..a6c7fb70 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/10.json @@ -0,0 +1,222 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "3b7929ee5871004f041f197d80d64e92", + "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, `reminderRepeat` TEXT 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": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "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)", + "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" + } + ], + "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" + ] + } + } + ], + "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, '3b7929ee5871004f041f197d80d64e92')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/11.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/11.json new file mode 100644 index 00000000..69d9350b --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/11.json @@ -0,0 +1,227 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "19a453440c873c4bc33f9d1b253708b5", + "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, `reminderRepeat` TEXT 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": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "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)", + "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" + } + ], + "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" + ] + } + } + ], + "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, '19a453440c873c4bc33f9d1b253708b5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/12.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/12.json new file mode 100644 index 00000000..94325631 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/12.json @@ -0,0 +1,233 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "91c5b2c87284f7cd0c7dc61fbf81806b", + "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)", + "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 + } + ], + "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)", + "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" + } + ], + "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" + ] + } + } + ], + "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, '91c5b2c87284f7cd0c7dc61fbf81806b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/8.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/8.json new file mode 100644 index 00000000..aee77c92 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/8.json @@ -0,0 +1,127 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "8cf66b446eaeb56b3afc5b3d80767d82", + "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, `reminderRepeat` TEXT 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": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "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, '8cf66b446eaeb56b3afc5b3d80767d82')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.pasich.mynotes.data.database.AppDatabase/9.json b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/9.json new file mode 100644 index 00000000..dc761f11 --- /dev/null +++ b/app/schemas/com.pasich.mynotes.data.database.AppDatabase/9.json @@ -0,0 +1,217 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "bea3a89761c9be78662a6a5d33c028a9", + "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, `reminderRepeat` TEXT 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": "reminderRepeat", + "columnName": "reminderRepeat", + "affinity": "TEXT", + "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, `isDone` INTEGER NOT NULL DEFAULT 0, `categoryId` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL DEFAULT 0, `position` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "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" + } + ], + "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" + ] + } + } + ], + "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, 'bea3a89761c9be78662a6a5d33c028a9')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java new file mode 100644 index 00000000..dff6621f --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentCleanupTest.java @@ -0,0 +1,63 @@ +package com.pasich.mynotes.data; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.extendedEditor.attach.AttachmentCleaner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public class AttachmentCleanupTest { + + private Context context; + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + public void deleteAttachmentFolderByNoteId_deletesFolder() throws Exception { + File base = new File(context.getFilesDir(), "attachments"); + File folder = new File(base, "note_9999"); + folder.mkdirs(); + File fakeFile = new File(folder, "test_image.jpg"); + fakeFile.createNewFile(); + + assertThat(folder.exists()).isTrue(); + assertThat(fakeFile.exists()).isTrue(); + + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 9999L); + + assertThat(folder.exists()).isFalse(); + assertThat(fakeFile.exists()).isFalse(); + } + + @Test + public void deleteAttachmentFolderByNoteId_nonExistentFolder_doesNotCrash() { + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 88888L); + } + + @Test + public void deleteAttachmentFolderByNoteId_multipleFiles_allDeleted() throws Exception { + File base = new File(context.getFilesDir(), "attachments"); + File folder = new File(base, "note_7777"); + folder.mkdirs(); + new File(folder, "file1.jpg").createNewFile(); + new File(folder, "file2.png").createNewFile(); + new File(folder, "file3.pdf").createNewFile(); + + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 7777L); + + assertThat(folder.exists()).isFalse(); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java new file mode 100644 index 00000000..85eabee4 --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/data/AttachmentStorageTest.java @@ -0,0 +1,64 @@ +package com.pasich.mynotes.data; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.extendedEditor.attach.AttachmentCleaner; +import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public class AttachmentStorageTest { + + private Context context; + + @Before + public void setUp() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + public void attachmentsBaseDir_isInFilesDir() { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + assertThat(base.getAbsolutePath()).startsWith(context.getFilesDir().getAbsolutePath()); + } + + @Test + public void createAndDeleteFolder_worksCorrectly() throws Exception { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + File noteFolder = new File(base, "note_55555"); + noteFolder.mkdirs(); + new File(noteFolder, "sample.jpg").createNewFile(); + assertThat(noteFolder.exists()).isTrue(); + + AttachmentCleaner.deleteAttachmentFolderByNoteId(context, 55555L); + + assertThat(noteFolder.exists()).isFalse(); + } + + @Test + public void cleanup_withNoAttachments_deletesOrphanFiles() throws Exception { + File base = new File(context.getFilesDir(), AttachmentStorage.ATTACHMENTS_BASE_DIR); + File noteFolder = new File(base, "note_66666"); + noteFolder.mkdirs(); + File orphan = new File(noteFolder, "orphan.jpg"); + orphan.createNewFile(); + + com.pasich.mynotes.data.model.Note emptyNote = + new com.pasich.mynotes.data.model.Note().create("", "", System.currentTimeMillis(), ""); + emptyNote.setId(66666); + + AttachmentCleaner.cleanup(context, emptyNote); + + assertThat(orphan.exists()).isFalse(); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java new file mode 100644 index 00000000..aa74256e --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/db/MigrationTest.java @@ -0,0 +1,73 @@ +package com.pasich.mynotes.db; + +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 org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +public class MigrationTest { + + private static final String TEST_DB = "migration-test"; + + @Rule + public final MigrationTestHelper helper = new MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase.class + ); + + @Test + public void migrate2to3_succeeds() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); + 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); + } + + @Test + public void migrate4to5_addsValueJsonAndRichContentColumns() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 4); + 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); + } + + @Test + public void migrate6to7_addIsTrashColumn() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 6); + db.close(); + helper.runMigrationsAndValidate(TEST_DB, 7, true, AppDatabase.MIGRATION_6_7); + } + + @Test + public void migrateAllStepsChained_succeeds() throws IOException { + SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2); + 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); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java new file mode 100644 index 00000000..6340d22d --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/db/NoteRepositoryTest.java @@ -0,0 +1,138 @@ +package com.pasich.mynotes.db; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.dao.NoteDao; +import com.pasich.mynotes.data.model.Note; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; +import java.util.Date; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class NoteRepositoryTest { + + private AppDatabase db; + private NoteDao noteDao; + + @Before + public void setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().getTargetContext(), + AppDatabase.class + ).allowMainThreadQueries().build(); + noteDao = db.noteDao(); + } + + @After + public void tearDown() { + db.close(); + } + + private Note makeNote(String title, String value) { + return new Note().create(title, value, new Date().getTime(), ""); + } + + @Test + public void addNote_andQueryAll_returnsNote() { + noteDao.addNote(makeNote("Hello", "World")); + List result = noteDao.getNotesAll().blockingFirst(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("Hello"); + } + + @Test + public void addNote_isNotInTrash_byDefault() { + noteDao.addNote(makeNote("Draft", "content")); + List notes = noteDao.getNotesAll().blockingFirst(); + assertThat(notes.get(0).isTrash()).isFalse(); + } + + @Test + public void deleteNote_removesFromDb() { + Note note = makeNote("Delete me", "soon"); + long id = noteDao.addNote(note); + note.setId((int) id); + noteDao.deleteNote(note); + List result = noteDao.getNotesAll().blockingFirst(); + assertThat(result).isEmpty(); + } + + @Test + public void updateNote_changesTitle() { + Note note = makeNote("Old", "value"); + long id = noteDao.addNote(note); + note.setId((int) id); + note.setTitle("New"); + noteDao.updateNote(note); + Note updated = noteDao.getNoteForId(id).blockingGet(); + assertThat(updated.getTitle()).isEqualTo("New"); + } + + @Test + public void moveToTrash_notesAppearInTrash() { + Note note = makeNote("Trash me", "bye"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + List trash = noteDao.getTrashNotes().blockingFirst(); + assertThat(trash).hasSize(1); + assertThat(trash.get(0).isTrash()).isTrue(); + } + + @Test + public void moveToTrash_notesDisappearFromMain() { + Note note = makeNote("Moving", "out"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + List main = noteDao.getNotesAll().blockingFirst(); + assertThat(main).isEmpty(); + } + + @Test + public void restoreFromTrash_notesReturnToMain() { + Note note = makeNote("Restore me", "please"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + noteDao.restoreNotesFromTrash(Collections.singletonList(id)); + List main = noteDao.getNotesAll().blockingFirst(); + assertThat(main).hasSize(1); + assertThat(main.get(0).isTrash()).isFalse(); + } + + @Test + public void deleteAllTrashNotes_clearsTrash() { + Note note = makeNote("Trash", "gone"); + int id = noteDao.addNote(note).intValue(); + noteDao.moveNotesToTrash(Collections.singletonList(id)); + noteDao.deleteAllTrashNotes(); + List trash = noteDao.getTrashNotes().blockingFirst(); + assertThat(trash).isEmpty(); + } + + @Test + public void getNoteForId_returnsCorrectNote() { + long idA = noteDao.addNote(makeNote("Alpha", "a")); + noteDao.addNote(makeNote("Beta", "b")); + Note found = noteDao.getNoteForId(idA).blockingGet(); + assertThat(found.getTitle()).isEqualTo("Alpha"); + } + + @Test + public void addMultipleNotes_allAppearInList() { + noteDao.addNote(makeNote("One", "1")); + noteDao.addNote(makeNote("Two", "2")); + noteDao.addNote(makeNote("Three", "3")); + List all = noteDao.getNotesAll().blockingFirst(); + assertThat(all).hasSize(3); + } +} diff --git a/app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java b/app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java new file mode 100644 index 00000000..c7b56f38 --- /dev/null +++ b/app/src/androidTest/java/com/pasich/mynotes/db/TagsRepositoryTest.java @@ -0,0 +1,85 @@ +package com.pasich.mynotes.db; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.dao.TagsDao; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.utils.managers.SystemTagsManager; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class TagsRepositoryTest { + + private AppDatabase db; + private TagsDao tagsDao; + + @Before + public void setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().getTargetContext(), + AppDatabase.class + ).allowMainThreadQueries().build(); + tagsDao = db.tagsDao(); + } + + @After + public void tearDown() { + db.close(); + } + + private Tag makeUserTag(String name) { + return new Tag().create(name); + } + + @Test + public void addTag_appearsInList() { + tagsDao.addTag(makeUserTag("Work")); + List tags = tagsDao.getTags().blockingFirst(); + boolean found = tags.stream().anyMatch(t -> "Work".equals(t.getNameTag())); + assertThat(found).isTrue(); + } + + @Test + public void deleteTag_removesFromList() { + tagsDao.addTag(makeUserTag("Temp")); + List before = tagsDao.getTags().blockingFirst(); + Tag inserted = before.stream().filter(t -> "Temp".equals(t.getNameTag())).findFirst().orElseThrow(RuntimeException::new); + tagsDao.deleteTag(inserted); + List after = tagsDao.getTags().blockingFirst(); + boolean stillExists = after.stream().anyMatch(t -> "Temp".equals(t.getNameTag())); + assertThat(stillExists).isFalse(); + } + + @Test + public void updateTag_changesName() { + tagsDao.addTag(makeUserTag("OldName")); + List list = tagsDao.getTags().blockingFirst(); + Tag tag = list.stream().filter(t -> "OldName".equals(t.getNameTag())).findFirst().orElseThrow(RuntimeException::new); + tag.setNameTag("NewName"); + tagsDao.updateTag(tag); + List updated = tagsDao.getTags().blockingFirst(); + boolean newNameExists = updated.stream().anyMatch(t -> "NewName".equals(t.getNameTag())); + assertThat(newNameExists).isTrue(); + } + + @Test + public void getTagsUser_returnsOnlyUserTags() { + tagsDao.addTag(makeUserTag("Personal")); + tagsDao.addTag(makeUserTag("Work")); + List userTags = tagsDao.getTagsUser().blockingFirst(); + for (Tag t : userTags) { + assertThat(t.getSystemAction()).isEqualTo(SystemTagsManager.SYSTEM_ACTION_USER_TAG); + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa6db52a..db1af477 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + @@ -133,6 +136,39 @@ + + + + + + + + + + + + + + + + + -