From 472a96427de47687afa7acfd2898859639ca1826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 10 Jul 2026 15:23:41 +0300 Subject: [PATCH] Remove obsolete Keeper functionality --- Keeper/.gitignore | 6 - Keeper/README.md | 62 - Keeper/build.gradle | 22 - Keeper/gradle.properties | 6 - Keeper/gradle/libs.versions.toml | 133 - Keeper/gradle/wrapper/gradle-wrapper.jar | Bin 59536 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 - Keeper/gradlew | 234 - Keeper/gradlew.bat | 89 - Keeper/keeperApp/build.gradle | 54 - Keeper/keeperApp/proguard-rules.pro | 1 - Keeper/keeperApp/src/main/AndroidManifest.xml | 57 - .../java/eu/qsfera/keeper/MainActivity.kt | 3883 ----------------- .../res/drawable/ic_account_circle_24.xml | 9 - .../src/main/res/drawable/ic_add_24.xml | 10 - .../src/main/res/drawable/ic_archive_24.xml | 10 - .../src/main/res/drawable/ic_checklist_24.xml | 10 - .../src/main/res/drawable/ic_delete_24.xml | 10 - .../src/main/res/drawable/ic_draw_24.xml | 10 - .../src/main/res/drawable/ic_image_24.xml | 10 - .../src/main/res/drawable/ic_keeper_icon.xml | 26 - .../src/main/res/drawable/ic_menu_24.xml | 9 - .../src/main/res/drawable/ic_mic_24.xml | 10 - .../main/res/drawable/ic_notification_24.xml | 10 - .../src/main/res/drawable/ic_play_24.xml | 10 - .../src/main/res/drawable/ic_push_pin_24.xml | 10 - .../src/main/res/drawable/ic_restore_24.xml | 10 - .../src/main/res/drawable/ic_search_24.xml | 10 - .../src/main/res/drawable/ic_share_24.xml | 10 - .../src/main/res/drawable/ic_sync_24.xml | 10 - .../keeperApp/src/main/res/values/colors.xml | 10 - .../keeperApp/src/main/res/values/strings.xml | 136 - .../keeperApp/src/main/res/values/styles.xml | 13 - .../keeperApp/src/main/res/xml/file_paths.xml | 15 - .../main/res/xml/network_security_config.xml | 8 - Keeper/qkeep-runtime-0.1.21-pulled.png | Bin 15580 -> 0 bytes Keeper/qkeep-runtime-0.1.21.png | Bin 31162 -> 0 bytes Keeper/scripts/publish-argus.ps1 | 99 - Keeper/settings.gradle | 21 - Server/services/keepernotes/README.md | 20 - .../keepernotes/cmd/keepernotes/main.go | 37 - .../services/keepernotes/pkg/notes/server.go | 105 - .../services/keepernotes/pkg/notes/store.go | 229 - .../keepernotes/pkg/notes/store_test.go | 94 - 44 files changed, 5523 deletions(-) delete mode 100644 Keeper/.gitignore delete mode 100644 Keeper/README.md delete mode 100644 Keeper/build.gradle delete mode 100644 Keeper/gradle.properties delete mode 100644 Keeper/gradle/libs.versions.toml delete mode 100644 Keeper/gradle/wrapper/gradle-wrapper.jar delete mode 100644 Keeper/gradle/wrapper/gradle-wrapper.properties delete mode 100644 Keeper/gradlew delete mode 100644 Keeper/gradlew.bat delete mode 100644 Keeper/keeperApp/build.gradle delete mode 100644 Keeper/keeperApp/proguard-rules.pro delete mode 100644 Keeper/keeperApp/src/main/AndroidManifest.xml delete mode 100644 Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_account_circle_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_add_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_archive_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_checklist_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_delete_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_draw_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_image_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_keeper_icon.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_menu_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_mic_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_notification_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_play_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_push_pin_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_restore_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_search_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_share_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/drawable/ic_sync_24.xml delete mode 100644 Keeper/keeperApp/src/main/res/values/colors.xml delete mode 100644 Keeper/keeperApp/src/main/res/values/strings.xml delete mode 100644 Keeper/keeperApp/src/main/res/values/styles.xml delete mode 100644 Keeper/keeperApp/src/main/res/xml/file_paths.xml delete mode 100644 Keeper/keeperApp/src/main/res/xml/network_security_config.xml delete mode 100644 Keeper/qkeep-runtime-0.1.21-pulled.png delete mode 100644 Keeper/qkeep-runtime-0.1.21.png delete mode 100644 Keeper/scripts/publish-argus.ps1 delete mode 100644 Keeper/settings.gradle delete mode 100644 Server/services/keepernotes/README.md delete mode 100644 Server/services/keepernotes/cmd/keepernotes/main.go delete mode 100644 Server/services/keepernotes/pkg/notes/server.go delete mode 100644 Server/services/keepernotes/pkg/notes/store.go delete mode 100644 Server/services/keepernotes/pkg/notes/store_test.go diff --git a/Keeper/.gitignore b/Keeper/.gitignore deleted file mode 100644 index 94fce493..00000000 --- a/Keeper/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -.gradle/ -build/ -local.properties -keeperApp/build/ -*.apk -*.aab diff --git a/Keeper/README.md b/Keeper/README.md deleted file mode 100644 index 86ffed87..00000000 --- a/Keeper/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# QKeep / КуЗаметки - -Android notes app published as QKeep and shown on Android as КуЗаметки. It stores notes locally, supports pinned notes, formatted text notes, checklist notes with configurable list behavior, image and drawing notes, voice notes, labels, QSfera collaborators, timed reminders, search filters by note type and color, sending notes to other Android apps, manual card ordering, archive and trash sections, and syncs them with the Keeper Notes REST service in `G:\Repos\QSfera\Server\services\keepernotes`. - -## Build - -The project follows the same baseline as `G:\Repos\QSfera\Android`: Android Gradle Plugin 8.7.2, Kotlin 1.9.20, compile SDK 35, target SDK 35, min SDK 26, and Java 17. - -```powershell -$env:JAVA_HOME='C:\Program Files\Android\Android Studio\jbr' -$env:ANDROID_HOME='C:\Users\seven\AppData\Local\Android\Sdk' -$env:ANDROID_SDK_ROOT=$env:ANDROID_HOME -.\gradlew.bat :keeperApp:assembleDebug -``` - -The default backend URL is `https://qsfera.kusoft.xyz/keeper-notes`. The default QSfera cloud used for first-run authorization is `https://qsfera.kusoft.xyz`. - -To use another backend URL: - -```powershell -.\gradlew.bat :keeperApp:assembleDebug -PkeeperApiBaseUrl=https://example.org -``` - -To use another QSfera cloud URL for authorization: - -```powershell -.\gradlew.bat :keeperApp:assembleDebug -PqsferaCloudBaseUrl=https://qsfera.example.org -``` - -## Server - -From `G:\Repos\QSfera\Server`: - -```powershell -go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.json -``` - -## Publish to Argus - -The Argus publisher script uses the admin endpoints exposed by `https://argus.kusoft.xyz/site.js`: - -* `POST /api/admin/session/login` -* `PUT /api/admin/apps/{slug}` -* `POST /api/admin/apps/{slug}/releases` - -Set credentials in the current shell, then run: - -```powershell -$env:ARGUS_USERNAME='admin' -$env:ARGUS_PASSWORD='' -.\scripts\publish-argus.ps1 -``` - -Default publish metadata: - -* slug: `keeper-android` -* package: `eu.qsfera.keeper` -* name: `QKeep` -* Android label: `КуЗаметки` -* version: `0.1.21` -* Android version code: `22` -* channel: `stable` diff --git a/Keeper/build.gradle b/Keeper/build.gradle deleted file mode 100644 index 3609066d..00000000 --- a/Keeper/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -buildscript { - ext { - sdkCompileVersion = 35 - sdkMinVersion = 26 - sdkTargetVersion = 35 - } - - repositories { - google() - mavenCentral() - maven { url "https://plugins.gradle.org/m2/" } - } - - dependencies { - classpath libs.android.gradlePlugin - classpath libs.kotlin.gradlePlugin - } -} - -plugins { - alias libs.plugins.ksp apply false -} diff --git a/Keeper/gradle.properties b/Keeper/gradle.properties deleted file mode 100644 index 2989ec97..00000000 --- a/Keeper/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -android.enableJetifier=true -android.nonFinalResIds=false -android.nonTransitiveRClass=false -android.useAndroidX=true -org.gradle.jvmargs=-Xmx1536M -android.overridePathCheck=true diff --git a/Keeper/gradle/libs.versions.toml b/Keeper/gradle/libs.versions.toml deleted file mode 100644 index 3f006acc..00000000 --- a/Keeper/gradle/libs.versions.toml +++ /dev/null @@ -1,133 +0,0 @@ -[versions] -androidGradlePlugin = "8.7.2" -androidxActivity = "1.8.0" -androidxAnnotation = "1.6.0" -androidxAppCompat = "1.6.1" -androidxArch = "2.2.0" -androidxBiometric = "1.1.0" -androidxBrowser = "1.5.0" -androidxContraintLayout = "2.1.4" -androidxCore = "1.10.1" -androidxEnterpriseFeedback = "1.1.0" -androidxEspresso = "3.6.1" -androidxFragment = "1.5.7" -androidxLegacy = "1.0.0" -androidxLifecycle = "2.5.1" -androidxLifecycleExtensions = "2.2.0" -androidxRoom = "2.5.1" -androidxSqlite = "2.3.1" -androidxTest = "1.6.1" -androidxTestExt = "1.2.1" -androidxTestMonitor = "1.7.2" -androidxTestUiAutomator ="2.3.0" -androidxWork = "2.8.1" -coil = "2.2.2" -detekt = "1.23.8" -dexopener = "2.0.5" -disklrucache = "2.0.2" -media3 ="1.1.1" -floatingactionbutton = "1.10.1" -glide = "4.15.1" -glideToVectorYou = "v2.0.0" -junit4 = "4.13.2" -kaspresso = "1.6.1" -koin = "3.3.3" -kotlin = "1.9.20" -kotlinxCoroutines = "1.6.4" -ksp = "1.9.20-1.0.14" -ktlint = "11.1.0" -markwon = "4.6.2" -material = "1.8.0" -mockk = "1.13.13" -moshi = "1.15.2" -patternlockview = "a90b0d4bf0" -photoView = "2.3.0" -preference = "1.2.0" -sonarqube = "4.0.0.2929" -stetho = "1.6.0" -timber = "5.0.1" - -[libraries] -androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidxActivity" } -androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } -androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArch" } -androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" } -androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } -androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidxContraintLayout" } -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } -androidx-enterprise-feedback = { group = "androidx.enterprise", name = "enterprise-feedback", version.ref = "androidxEnterpriseFeedback" } -androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" } -androidx-fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "androidxFragment" } -androidx-legacy-support = { group = "androidx.legacy", name = "legacy-support-v4", version.ref = "androidxLegacy" } -androidx-lifecycle-common-java8 = { group = "androidx.lifecycle", name = "lifecycle-common-java8", version.ref = "androidxLifecycle" } -androidx-lifecycle-extensions = { group = "androidx.lifecycle", name = "lifecycle-extensions", version.ref = "androidxLifecycleExtensions" } -androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } -androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } -androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" } -androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" } -androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidxRoom" } -androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidxRoom" } -androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "androidxRoom" } -androidx-sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.ref = "androidxSqlite" } -androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTest" } -androidx-test-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "androidxEspresso" } -androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } -androidx-test-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "androidxEspresso" } -androidx-test-espresso-web = { group = "androidx.test.espresso", name = "espresso-web", version.ref = "androidxEspresso" } -androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } -androidx-test-monitor = { group = "androidx.test", name = "monitor", version.ref = "androidxTestMonitor" } -androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" } -androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxTestUiAutomator" } -androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } -coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } -detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } -detekt-libraries = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" } -dexopener = { group = "com.github.tmurakami", name = "dexopener", version.ref = "dexopener" } -disklrucache = { group = "com.jakewharton", name = "disklrucache", version.ref = "disklrucache" } -media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } -media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } -floatingactionbutton = { group = "com.getbase", name = "floatingactionbutton", version.ref = "floatingactionbutton" } -glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } -glide-vector = { group = "com.github.2coffees1team", name = "GlideToVectorYou", version.ref = "glideToVectorYou" } -junit4 = { group = "junit", name = "junit", version.ref = "junit4" } -kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" } -koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } -koin-androidx-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager", version.ref = "koin" } -koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } -kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } -markwon-core = { group = "io.noties.markwon", name = "core", version.ref = "markwon" } -markwon-ext-strikethrough = { group = "io.noties.markwon", name = "ext-strikethrough", version.ref = "markwon" } -markwon-ext-tables = { group = "io.noties.markwon", name = "ext-tables", version.ref = "markwon" } -markwon-ext-tasklist = { group = "io.noties.markwon", name = "ext-tasklist", version.ref = "markwon" } -markwon-html = { group = "io.noties.markwon", name = "html", version.ref = "markwon" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } -mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } -mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } -moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } -moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } -patternlockview = { group = "com.github.aritraroy.PatternLockView", name = "patternlockview", version.ref = "patternlockview" } -photoview = { group = "com.github.chrisbanes", name = "PhotoView", version.ref = "photoView" } -stetho = { group = "com.facebook.stetho", name = "stetho", version.ref = "stetho" } -timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } - -# Dependencies of the included build-logic -android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } -kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } -ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } -ktlint-gradlePlugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlint" } - -[bundles] -espresso = ["androidx-test-espresso-contrib", "androidx-test-espresso-core", "androidx-test-espresso-intents", "androidx-test-espresso-web"] -markwon = ["markwon-core", "markwon-ext-tables", "markwon-ext-strikethrough", "markwon-ext-tasklist", "markwon-html"] - -[plugins] -kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } -detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/Keeper/gradle/wrapper/gradle-wrapper.jar b/Keeper/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7454180f2ae8848c63b8b4dea2cb829da983f2fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL diff --git a/Keeper/gradle/wrapper/gradle-wrapper.properties b/Keeper/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 19cfad96..00000000 --- a/Keeper/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/Keeper/gradlew b/Keeper/gradlew deleted file mode 100644 index 1b6c7873..00000000 --- a/Keeper/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/Keeper/gradlew.bat b/Keeper/gradlew.bat deleted file mode 100644 index 107acd32..00000000 --- a/Keeper/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Keeper/keeperApp/build.gradle b/Keeper/keeperApp/build.gradle deleted file mode 100644 index de57180f..00000000 --- a/Keeper/keeperApp/build.gradle +++ /dev/null @@ -1,54 +0,0 @@ -apply plugin: "com.android.application" -apply plugin: "kotlin-android" - -dependencies { - implementation libs.kotlin.stdlib - implementation libs.kotlinx.coroutines.android - implementation libs.kotlinx.coroutines.core - - implementation libs.androidx.activity - implementation libs.androidx.appcompat - implementation libs.androidx.core.ktx - implementation libs.androidx.lifecycle.runtime.ktx - implementation libs.material - - testImplementation libs.junit4 -} - -android { - namespace "eu.qsfera.keeper" - compileSdkVersion sdkCompileVersion - - defaultConfig { - applicationId "eu.qsfera.keeper" - minSdkVersion sdkMinVersion - targetSdkVersion sdkTargetVersion - versionCode 22 - versionName "0.1.21" - - def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes" - def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz" - buildConfigField "String", "KEEPER_API_BASE_URL", "\"${apiBaseUrl}\"" - buildConfigField "String", "QSFERA_CLOUD_BASE_URL", "\"${cloudBaseUrl}\"" - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" - } - } - - buildFeatures { - buildConfig true - } -} diff --git a/Keeper/keeperApp/proguard-rules.pro b/Keeper/keeperApp/proguard-rules.pro deleted file mode 100644 index b7453027..00000000 --- a/Keeper/keeperApp/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ -# The app currently uses only Android framework APIs and direct HTTP calls. diff --git a/Keeper/keeperApp/src/main/AndroidManifest.xml b/Keeper/keeperApp/src/main/AndroidManifest.xml deleted file mode 100644 index bb06f82f..00000000 --- a/Keeper/keeperApp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt b/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt deleted file mode 100644 index e509c90d..00000000 --- a/Keeper/keeperApp/src/main/java/eu/qsfera/keeper/MainActivity.kt +++ /dev/null @@ -1,3883 +0,0 @@ -package eu.qsfera.keeper - -import android.Manifest -import android.app.AlarmManager -import android.app.DatePickerDialog -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.TimePickerDialog -import android.content.BroadcastReceiver -import android.content.ContentValues -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.res.ColorStateList -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Path -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.media.MediaPlayer -import android.media.MediaRecorder -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Environment -import android.provider.Settings -import android.text.Editable -import android.text.InputType -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.style.RelativeSizeSpan -import android.text.style.StyleSpan -import android.text.style.UnderlineSpan -import android.util.Base64 -import android.view.Gravity -import android.view.MotionEvent -import android.view.DragEvent -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.GridLayout -import android.widget.CheckBox -import android.widget.HorizontalScrollView -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.PopupMenu -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.widget.doAfterTextChanged -import androidx.core.content.FileProvider -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.google.android.material.button.MaterialButton -import com.google.android.material.card.MaterialCardView -import com.google.android.material.floatingactionbutton.FloatingActionButton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import java.nio.file.Files -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder -import java.security.MessageDigest -import java.security.SecureRandom -import java.text.DateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import java.util.UUID - -private const val DATABASE_NAME = "keeper_notes.db" -private const val DATABASE_VERSION = 10 -private const val OAUTH_CLIENT_ID = "QsferaAndroid" -private const val OAUTH_REDIRECT_URI = "oc://android.qsfera.eu" -private const val OAUTH_SCOPE = "openid offline_access email profile" -private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz" -private const val ARGUS_MANIFEST_URL = "$ARGUS_BASE_URL/api/apps/keeper-android/manifest?platform=android&channel=stable" -private const val MENU_SETTINGS = 1 -private const val MENU_LOGOUT = 2 -private const val MENU_NOTES = 3 -private const val MENU_ARCHIVE = 4 -private const val MENU_TRASH = 5 -private const val MENU_REMINDERS = 6 -private const val MENU_LABEL_BASE = 1000 -private const val TOP_SAFE_AREA_EXTRA_DP = 12 -private const val BOTTOM_SAFE_AREA_EXTRA_DP = 12 -private const val APK_MIME_TYPE = "application/vnd.android.package-archive" -private const val REMINDER_CHANNEL_ID = "qkeep_reminders" -private const val REMINDER_NOTIFICATION_GROUP = "qkeep_reminders_group" -private const val EXTRA_NOTE_ID = "eu.qsfera.keeper.extra.NOTE_ID" -private const val EXTRA_NOTE_TITLE = "eu.qsfera.keeper.extra.NOTE_TITLE" -private const val EXTRA_NOTE_BODY = "eu.qsfera.keeper.extra.NOTE_BODY" -private const val ACTION_SHOW_REMINDER = "eu.qsfera.keeper.action.SHOW_REMINDER" -private const val REQUEST_PICK_IMAGE = 7101 -private const val MAX_IMAGE_DIMENSION = 1280 -private const val IMAGE_JPEG_QUALITY = 82 -private const val NOTE_DRAG_LABEL = "qkeep-note" -private const val RECORD_AUDIO_PERMISSION_REQUEST = 7102 - -private val NOTE_COLORS = intArrayOf( - Color.rgb(255, 244, 117), - Color.rgb(255, 255, 255), - Color.rgb(242, 139, 130), - Color.rgb(251, 188, 4), - Color.rgb(204, 255, 144), - Color.rgb(167, 255, 235), - Color.rgb(203, 240, 248), - Color.rgb(215, 174, 251), -) - -private enum class NoteSection { - NOTES, - REMINDERS, - ARCHIVE, - TRASH -} - -private enum class SearchTypeFilter(val labelRes: Int) { - ALL(R.string.filter_all), - TEXT(R.string.filter_text), - LISTS(R.string.filter_lists), - IMAGES(R.string.filter_images), - DRAWINGS(R.string.filter_drawings), - RECORDINGS(R.string.filter_recordings), - REMINDERS(R.string.filter_reminders) -} - -enum class BodyTextStyle { - BOLD, - ITALIC, - UNDERLINE, - H1, - H2 -} - -class MainActivity : AppCompatActivity() { - private lateinit var authPreferences: AuthPreferences - private lateinit var keepSettings: KeepSettings - private lateinit var authClient: QsferaAuthClient - private lateinit var repository: NotesRepository - private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL) - private lateinit var grid: GridLayout - private lateinit var emptyView: TextView - private lateinit var searchInput: EditText - private lateinit var searchFilters: LinearLayout - private lateinit var statusText: TextView - private val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) - - private var notes: List = emptyList() - private var currentSection: NoteSection = NoteSection.NOTES - private var currentLabel: String? = null - private var selectedSearchType: SearchTypeFilter = SearchTypeFilter.ALL - private var selectedSearchColor: Int? = null - private var syncStatusLabel: String = "" - private var pendingImageSelection: ((ImageAttachment) -> Unit)? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - authPreferences = AuthPreferences(applicationContext) - keepSettings = KeepSettings(applicationContext) - authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL) - repository = NotesRepository(applicationContext, authPreferences, authClient) - configureSystemBars() - VersionCleanup(applicationContext).cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) - ReminderScheduler(applicationContext).ensureNotificationChannel() - ensureNotificationPermission() - - if (isOAuthRedirect(intent)) { - handleOAuthRedirect(intent) - } else if (authPreferences.currentSession() == null) { - showLogin() - } else { - showNotes(sync = true) - } - } - - private fun configureSystemBars() { - WindowCompat.setDecorFitsSystemWindows(window, false) - window.statusBarColor = Color.WHITE - window.navigationBarColor = Color.WHITE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - window.attributes = window.attributes.apply { - layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - if (isOAuthRedirect(intent)) { - handleOAuthRedirect(intent) - } - } - - @Deprecated("Deprecated Android activity result callback is sufficient for this single-action picker.") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode != REQUEST_PICK_IMAGE) return - val callback = pendingImageSelection - pendingImageSelection = null - val uri = data?.data - if (resultCode != RESULT_OK || callback == null || uri == null) return - lifecycleScope.launch { - val result = runCatching { - withContext(Dispatchers.IO) { - imageAttachmentFromUri(uri) - } - } - result - .onSuccess { attachment -> callback(attachment) } - .onFailure { - Toast.makeText(this@MainActivity, getString(R.string.image_pick_failed), Toast.LENGTH_SHORT).show() - } - } - } - - private fun pickImage(onSelected: (ImageAttachment) -> Unit) { - pendingImageSelection = onSelected - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "image/*" - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - try { - startActivityForResult(intent, REQUEST_PICK_IMAGE) - } catch (_: ActivityNotFoundException) { - pendingImageSelection = null - Toast.makeText(this, getString(R.string.image_pick_failed), Toast.LENGTH_SHORT).show() - } - } - - private fun imageAttachmentFromUri(uri: Uri): ImageAttachment { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - contentResolver.openInputStream(uri)?.use { input -> - BitmapFactory.decodeStream(input, null, bounds) - } - val sampleSize = imageSampleSize(bounds.outWidth, bounds.outHeight) - val bitmap = contentResolver.openInputStream(uri)?.use { input -> - BitmapFactory.decodeStream(input, null, BitmapFactory.Options().apply { inSampleSize = sampleSize }) - } ?: throw IOException("Unable to decode image") - val bytes = ByteArrayOutputStream().use { output -> - bitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_JPEG_QUALITY, output) - output.toByteArray() - } - return ImageAttachment( - id = UUID.randomUUID().toString(), - mimeType = "image/jpeg", - base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - ) - } - - private fun imageSampleSize(width: Int, height: Int): Int { - var sampleSize = 1 - var scaledWidth = width - var scaledHeight = height - while (scaledWidth > MAX_IMAGE_DIMENSION || scaledHeight > MAX_IMAGE_DIMENSION) { - sampleSize *= 2 - scaledWidth /= 2 - scaledHeight /= 2 - } - return sampleSize.coerceAtLeast(1) - } - - private fun showLogin() { - setContentView(createLoginView()) - } - - private fun showNotes(sync: Boolean) { - setContentView(createContentView()) - reloadNotes(sync = sync) - } - - private fun isOAuthRedirect(intent: Intent?): Boolean { - val uri = intent?.data ?: return false - return uri.scheme == "oc" && uri.host == "android.qsfera.eu" - } - - private fun handleOAuthRedirect(intent: Intent) { - val uri = intent.data ?: run { - showLogin() - return - } - val error = uri.getQueryParameter("error") - val code = uri.getQueryParameter("code") - val state = uri.getQueryParameter("state") - val pendingOAuth = authPreferences.pendingOAuth() - - if (error != null || code.isNullOrBlank() || state.isNullOrBlank() || pendingOAuth == null || state != pendingOAuth.state) { - authPreferences.clearPendingOAuth() - showLogin() - Toast.makeText(this, getString(R.string.cloud_login_failed), Toast.LENGTH_SHORT).show() - return - } - - setContentView(createOAuthProgressView()) - lifecycleScope.launch { - val result = runCatching { - withContext(Dispatchers.IO) { - authClient.exchangeCode(code, pendingOAuth.codeVerifier) - } - } - - result - .onSuccess { session -> - authPreferences.save(session) - authPreferences.clearPendingOAuth() - Toast.makeText( - this@MainActivity, - getString(R.string.signed_in_as, session.displayName), - Toast.LENGTH_SHORT - ).show() - showNotes(sync = true) - } - .onFailure { - authPreferences.clearPendingOAuth() - showLogin() - Toast.makeText(this@MainActivity, getString(R.string.cloud_login_failed), Toast.LENGTH_SHORT).show() - } - } - } - - private fun createOAuthProgressView(): View { - return TextView(this).apply { - setBackgroundColor(Color.WHITE) - gravity = Gravity.CENTER - text = getString(R.string.cloud_signing_in) - textSize = 16f - setTextColor(Color.rgb(95, 99, 104)) - applySafeAreaInsets() - } - } - - private fun createLoginView(): View { - val root = ScrollView(this).apply { - setBackgroundColor(Color.WHITE) - isFillViewport = true - applySafeAreaInsets() - } - - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(28), dp(56), dp(28), dp(56)) - } - - content.addView(TextView(this).apply { - text = getString(R.string.cloud_login_title) - textSize = 30f - setTypeface(typeface, Typeface.BOLD) - setTextColor(Color.rgb(32, 33, 36)) - gravity = Gravity.CENTER - }, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - content.addView(TextView(this).apply { - text = getString(R.string.cloud_login_server) - textSize = 15f - setTextColor(Color.rgb(95, 99, 104)) - gravity = Gravity.CENTER - setPadding(0, dp(8), 0, dp(28)) - }, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val usernameInput = loginInput(getString(R.string.cloud_username), password = false) - content.addView(usernameInput, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(56)).apply { - setMargins(0, 0, 0, dp(18)) - }) - - val status = TextView(this).apply { - text = getString(R.string.cloud_login_hint) - textSize = 13f - setTextColor(Color.rgb(95, 99, 104)) - gravity = Gravity.CENTER - minHeight = dp(28) - } - content.addView(status, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val signInButton = MaterialButton(this).apply { - text = getString(R.string.cloud_sign_in) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(251, 188, 4)) - cornerRadius = dp(28) - setAllCaps(false) - } - content.addView(signInButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(56)) - - signInButton.setOnClickListener { - val username = usernameInput.text?.toString().orEmpty().trim() - val request = authClient.createAuthorizationRequest(username.takeIf { it.isNotBlank() }) - authPreferences.savePendingOAuth(request.state, request.codeVerifier) - status.setTextColor(Color.rgb(95, 99, 104)) - status.text = getString(R.string.cloud_signing_in) - startActivity(Intent(Intent.ACTION_VIEW, request.uri)) - } - - root.addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) - return root - } - - private fun loginInput(hintText: String, password: Boolean): EditText { - return EditText(this).apply { - hint = hintText - textSize = 16f - setSingleLine(true) - setTextColor(Color.rgb(32, 33, 36)) - setHintTextColor(Color.rgb(95, 99, 104)) - setPadding(dp(16), 0, dp(16), 0) - background = roundedDrawable( - color = Color.rgb(241, 243, 244), - radius = dp(12).toFloat() - ) - inputType = if (password) { - InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } else { - InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS - } - } - } - - private fun createContentView(): View { - val root = FrameLayout(this).apply { - setBackgroundColor(Color.WHITE) - applySafeAreaInsets() - } - - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(12), dp(16), 0) - } - - content.addView(createSearchBar()) - content.addView(createSearchFilters(), LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - statusText = TextView(this).apply { - setTextColor(Color.rgb(95, 99, 104)) - textSize = 12f - setPadding(dp(4), dp(8), dp(4), dp(6)) - } - content.addView(statusText, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val scroll = ScrollView(this).apply { - clipToPadding = false - setPadding(0, 0, 0, dp(96)) - } - grid = GridLayout(this).apply { - columnCount = columnCountForScreen() - useDefaultMargins = false - } - scroll.addView( - grid, - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - ) - content.addView(scroll, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) - - emptyView = TextView(this).apply { - gravity = Gravity.CENTER - text = getString(R.string.empty_notes) - textSize = 18f - setTextColor(Color.rgb(95, 99, 104)) - visibility = View.GONE - } - - root.addView(content, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - root.addView(emptyView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - root.addView(createFab()) - return root - } - - private fun createSearchBar(): View { - val searchBar = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(14), 0, dp(4), 0) - background = roundedDrawable(Color.rgb(241, 243, 244), dp(28).toFloat()) - elevation = dp(1).toFloat() - } - - searchBar.addView( - ImageButton(this).apply { - setImageResource(R.drawable.ic_menu_24) - imageTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.TRANSPARENT, dp(24).toFloat()) - contentDescription = getString(R.string.menu) - setOnClickListener { showMainMenu(this) } - }, - LinearLayout.LayoutParams(dp(48), dp(48)) - ) - - searchBar.addView( - ImageView(this).apply { - setImageResource(R.drawable.ic_search_24) - imageTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - }, - LinearLayout.LayoutParams(dp(24), dp(24)) - ) - - searchInput = EditText(this).apply { - hint = getString(R.string.search_notes) - textSize = 16f - setSingleLine(true) - setTextColor(Color.rgb(32, 33, 36)) - setHintTextColor(Color.rgb(95, 99, 104)) - background = null - inputType = InputType.TYPE_CLASS_TEXT - setPadding(dp(12), 0, dp(8), 0) - doAfterTextChanged { renderNotes() } - } - searchBar.addView(searchInput, LinearLayout.LayoutParams(0, dp(56), 1f)) - - searchBar.addView( - ImageButton(this).apply { - setImageResource(R.drawable.ic_account_circle_24) - imageTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.TRANSPARENT, dp(24).toFloat()) - contentDescription = getString(R.string.account) - setOnClickListener { showAccountDialog() } - }, - LinearLayout.LayoutParams(dp(48), dp(48)) - ) - - searchBar.addView( - ImageButton(this).apply { - setImageResource(R.drawable.ic_sync_24) - imageTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.TRANSPARENT, dp(24).toFloat()) - contentDescription = getString(R.string.syncing) - setOnClickListener { reloadNotes(sync = true) } - }, - LinearLayout.LayoutParams(dp(48), dp(48)) - ) - - return searchBar - } - - private fun createSearchFilters(): View { - searchFilters = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(4), dp(10), dp(4), dp(4)) - } - - return HorizontalScrollView(this).apply { - isHorizontalScrollBarEnabled = false - overScrollMode = View.OVER_SCROLL_NEVER - addView(searchFilters, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - } - } - - private fun renderSearchFilters() { - if (!::searchFilters.isInitialized) return - searchFilters.removeAllViews() - - SearchTypeFilter.values().forEach { filter -> - searchFilters.addView(createSearchFilterChip( - label = getString(filter.labelRes), - selected = selectedSearchType == filter - ) { - selectedSearchType = filter - renderNotes() - }) - } - - NOTE_COLORS.forEachIndexed { index, color -> - searchFilters.addView(createColorFilterSwatch(index, color)) - } - } - - private fun createSearchFilterChip( - label: String, - selected: Boolean, - onClick: () -> Unit - ): TextView { - return TextView(this).apply { - text = label - textSize = 13f - setTextColor(Color.rgb(32, 33, 36)) - gravity = Gravity.CENTER - minHeight = dp(36) - setPadding(dp(14), 0, dp(14), 0) - background = roundedDrawable( - color = if (selected) Color.rgb(254, 247, 224) else Color.WHITE, - radius = dp(18).toFloat(), - strokeColor = if (selected) Color.rgb(251, 188, 4) else Color.rgb(218, 220, 224), - strokeWidth = dp(1) - ) - setOnClickListener { onClick() } - }.also { chip -> - chip.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(36)).apply { - setMargins(0, 0, dp(8), 0) - } - } - } - - private fun createColorFilterSwatch(index: Int, color: Int): View { - val selected = selectedSearchColor == color - return View(this).apply { - contentDescription = getString(R.string.filter_color_number, index + 1) - background = roundedDrawable( - color = color, - radius = dp(18).toFloat(), - strokeColor = if (selected) Color.rgb(32, 33, 36) else Color.rgb(218, 220, 224), - strokeWidth = if (selected) dp(2) else dp(1) - ) - setOnClickListener { - selectedSearchColor = if (selectedSearchColor == color) null else color - renderNotes() - } - }.also { swatch -> - swatch.layoutParams = LinearLayout.LayoutParams(dp(36), dp(36)).apply { - setMargins(0, 0, dp(8), 0) - } - } - } - - private fun showMainMenu(anchor: View) { - PopupMenu(this, anchor).apply { - menu.add(0, MENU_NOTES, 0, getString(R.string.notes_section)) - menu.add(0, MENU_REMINDERS, 1, getString(R.string.reminders_section)) - menu.add(0, MENU_ARCHIVE, 2, getString(R.string.archive_section)) - menu.add(0, MENU_TRASH, 3, getString(R.string.trash_section)) - val labels = availableLabels() - labels.forEachIndexed { index, label -> - menu.add(0, MENU_LABEL_BASE + index, 4 + index, getString(R.string.label_menu_item, label)) - } - menu.add(0, MENU_SETTINGS, 4 + labels.size, getString(R.string.settings)) - menu.add(0, MENU_LOGOUT, 5 + labels.size, getString(R.string.logout)) - setOnMenuItemClickListener { item -> - when (item.itemId) { - MENU_NOTES -> { - currentSection = NoteSection.NOTES - currentLabel = null - renderNotes() - true - } - MENU_REMINDERS -> { - currentSection = NoteSection.REMINDERS - currentLabel = null - renderNotes() - true - } - MENU_ARCHIVE -> { - currentSection = NoteSection.ARCHIVE - currentLabel = null - renderNotes() - true - } - MENU_TRASH -> { - currentSection = NoteSection.TRASH - currentLabel = null - renderNotes() - true - } - MENU_SETTINGS -> { - showSettingsDialog() - true - } - MENU_LOGOUT -> { - signOut() - true - } - else -> { - val labelIndex = item.itemId - MENU_LABEL_BASE - if (labelIndex in labels.indices) { - currentLabel = labels[labelIndex] - currentSection = NoteSection.NOTES - renderNotes() - true - } else { - false - } - } - } - } - show() - } - } - - private fun showSettingsDialog() { - var availableUpdate: AppUpdateInfo? = null - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(4), dp(4), dp(4), 0) - } - - body.addView(TextView(this).apply { - text = getString(R.string.installed_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) - textSize = 15f - setTextColor(Color.rgb(32, 33, 36)) - }) - - body.addView(TextView(this).apply { - text = getString(R.string.list_behavior_settings) - textSize = 14f - setTypeface(typeface, Typeface.BOLD) - setTextColor(Color.rgb(32, 33, 36)) - setPadding(0, dp(14), 0, dp(4)) - }) - - val addItemsBottomToggle = CheckBox(this).apply { - text = getString(R.string.add_new_items_to_bottom) - isChecked = keepSettings.addNewListItemsToBottom() - textSize = 14f - setTextColor(Color.rgb(32, 33, 36)) - buttonTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - setOnCheckedChangeListener { _, checked -> - keepSettings.setAddNewListItemsToBottom(checked) - } - } - body.addView(addItemsBottomToggle, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val moveCheckedBottomToggle = CheckBox(this).apply { - text = getString(R.string.move_checked_items_to_bottom) - isChecked = keepSettings.moveCheckedItemsToBottom() - textSize = 14f - setTextColor(Color.rgb(32, 33, 36)) - buttonTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - setOnCheckedChangeListener { _, checked -> - keepSettings.setMoveCheckedItemsToBottom(checked) - renderNotes() - } - } - body.addView(moveCheckedBottomToggle, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val updateStatus = TextView(this).apply { - text = getString(R.string.update_check_hint) - textSize = 13f - setTextColor(Color.rgb(95, 99, 104)) - setPadding(0, dp(10), 0, dp(12)) - } - body.addView(updateStatus, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - body.addView(TextView(this).apply { - text = getString(R.string.old_versions_cleanup) - textSize = 13f - setTextColor(Color.rgb(95, 99, 104)) - setPadding(0, 0, 0, dp(12)) - }) - - val checkButton = MaterialButton(this).apply { - text = getString(R.string.check_updates) - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(251, 188, 4)) - cornerRadius = dp(20) - } - body.addView(checkButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(48)) - - val installButton = MaterialButton(this).apply { - text = getString(R.string.install_update) - visibility = View.GONE - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(251, 188, 4)) - cornerRadius = dp(20) - } - body.addView(installButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(48)) - - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.settings) - .setView(body) - .setNegativeButton(R.string.close, null) - .create() - - checkButton.setOnClickListener { - checkButton.isEnabled = false - installButton.visibility = View.GONE - availableUpdate = null - updateStatus.text = getString(R.string.checking_updates) - lifecycleScope.launch { - val result = runCatching { - withContext(Dispatchers.IO) { - updateClient.latest() - } - } - checkButton.isEnabled = true - updateStatus.text = result.fold( - onSuccess = { latest -> - if (latest.androidVersionCode > BuildConfig.VERSION_CODE) { - availableUpdate = latest - installButton.visibility = View.VISIBLE - getString(R.string.update_available, latest.version, latest.androidVersionCode) - } else { - getString(R.string.up_to_date, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) - } - }, - onFailure = { - getString(R.string.update_check_failed) - } - ) - } - } - - installButton.setOnClickListener { - val update = availableUpdate ?: return@setOnClickListener - installButton.isEnabled = false - updateStatus.text = getString(R.string.downloading_update) - lifecycleScope.launch { - val result = runCatching { - withContext(Dispatchers.IO) { - updateClient.downloadPackage(applicationContext, update) - } - } - installButton.isEnabled = true - result - .onSuccess { apk -> - updateStatus.text = getString(R.string.opening_installer) - installApk(apk) - } - .onFailure { - updateStatus.text = getString(R.string.update_download_failed) - } - } - } - - dialog.show() - } - - private fun installApk(apk: File) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { - Toast.makeText(this, getString(R.string.allow_update_install), Toast.LENGTH_LONG).show() - startActivity( - Intent( - Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - Uri.parse("package:$packageName") - ) - ) - return - } - - val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", apk) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, APK_MIME_TYPE) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - try { - startActivity(intent) - } catch (_: ActivityNotFoundException) { - Toast.makeText(this, getString(R.string.update_installer_missing), Toast.LENGTH_LONG).show() - } - } - - private fun signOut() { - authPreferences.clear() - notes = emptyList() - showLogin() - } - - private fun showAccountDialog() { - val session = authPreferences.currentSession() - if (session == null) { - showLogin() - return - } - - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(4), dp(4), dp(4), 0) - } - body.addView(TextView(this).apply { - text = getString(R.string.signed_in_as, session.displayName) - textSize = 16f - setTextColor(Color.rgb(32, 33, 36)) - }) - body.addView(TextView(this).apply { - text = BuildConfig.QSFERA_CLOUD_BASE_URL - textSize = 13f - setTextColor(Color.rgb(95, 99, 104)) - setPadding(0, dp(6), 0, 0) - }) - - AlertDialog.Builder(this) - .setTitle(R.string.account) - .setView(body) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.logout) { _, _ -> - signOut() - } - .show() - } - - private fun createFab(): View { - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.END - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM or Gravity.END - ).apply { - setMargins(0, 0, dp(24), dp(24)) - } - addView(FloatingActionButton(this@MainActivity).apply { - size = FloatingActionButton.SIZE_MINI - setImageResource(R.drawable.ic_mic_24) - backgroundTintList = ColorStateList.valueOf(Color.WHITE) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - contentDescription = getString(R.string.new_voice_note) - setOnClickListener { - showAudioRecorderDialog { attachment -> - createAudioNote(attachment) - } - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { - setMargins(0, 0, 0, dp(12)) - }) - addView(FloatingActionButton(this@MainActivity).apply { - size = FloatingActionButton.SIZE_MINI - setImageResource(R.drawable.ic_draw_24) - backgroundTintList = ColorStateList.valueOf(Color.WHITE) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - contentDescription = getString(R.string.new_drawing_note) - setOnClickListener { - showDrawingDialog { attachment -> - createImageNote(attachment) - } - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { - setMargins(0, 0, 0, dp(12)) - }) - addView(FloatingActionButton(this@MainActivity).apply { - size = FloatingActionButton.SIZE_MINI - setImageResource(R.drawable.ic_image_24) - backgroundTintList = ColorStateList.valueOf(Color.WHITE) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - contentDescription = getString(R.string.new_image_note) - setOnClickListener { - pickImage { attachment -> - createImageNote(attachment) - } - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { - setMargins(0, 0, 0, dp(12)) - }) - addView(FloatingActionButton(this@MainActivity).apply { - size = FloatingActionButton.SIZE_MINI - setImageResource(R.drawable.ic_checklist_24) - backgroundTintList = ColorStateList.valueOf(Color.WHITE) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - contentDescription = getString(R.string.new_list) - setOnClickListener { showEditor(null, startChecklist = true) } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { - setMargins(0, 0, 0, dp(12)) - }) - addView(FloatingActionButton(this@MainActivity).apply { - setImageResource(R.drawable.ic_add_24) - backgroundTintList = ColorStateList.valueOf(Color.rgb(251, 188, 4)) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - contentDescription = getString(R.string.new_note) - setOnClickListener { showEditor(null) } - }, LinearLayout.LayoutParams(dp(56), dp(56))) - } - } - - private fun reloadNotes(sync: Boolean) { - if (authPreferences.currentSession() == null) { - Toast.makeText(this, getString(R.string.auth_required), Toast.LENGTH_SHORT).show() - showLogin() - return - } - - lifecycleScope.launch { - if (sync) { - statusText.text = getString(R.string.syncing) - val result = repository.sync() - notes = result.notes - if (result.authRequired) { - Toast.makeText(this@MainActivity, getString(R.string.auth_required), Toast.LENGTH_SHORT).show() - showLogin() - return@launch - } - syncStatusLabel = if (result.synced) getString(R.string.synced) else getString(R.string.local_mode) - if (!result.synced) { - Toast.makeText(this@MainActivity, getString(R.string.sync_error), Toast.LENGTH_SHORT).show() - } - } else { - notes = repository.readLocalNotes() - syncStatusLabel = getString(R.string.local_mode) - } - ReminderScheduler(applicationContext).reschedule(notes) - renderNotes() - } - } - - private fun renderNotes() { - if (!::grid.isInitialized) return - - renderSearchFilters() - - val query = searchInput.text?.toString().orEmpty().trim().lowercase(Locale.getDefault()) - val visibleNotes = notes - .filter { note -> note.belongsToCurrentView() } - .filter { note -> - note.matchesQuery(query) - } - .filter { note -> note.matchesSearchFilters() } - .sortedWith(noteOrderComparator()) - - grid.removeAllViews() - grid.columnCount = columnCountForScreen() - searchInput.hint = currentLabel?.let { getString(R.string.search_label, it) } ?: getString(searchHintRes(currentSection)) - val activeFilterLabel = activeSearchFilterLabel() - val statusSuffix = listOfNotNull( - syncStatusLabel.ifBlank { getString(R.string.local_mode) }, - activeFilterLabel - ).joinToString(" - ") - statusText.text = getString( - R.string.section_status, - currentLabel?.let { getString(R.string.label_section, it) } ?: getString(sectionTitleRes(currentSection)), - statusSuffix - ) - emptyView.text = if (query.isNotBlank() || hasActiveSearchFilter()) { - getString(R.string.empty_search_results) - } else { - currentLabel?.let { getString(R.string.empty_label) } ?: getString(emptyTextRes(currentSection)) - } - - emptyView.visibility = if (visibleNotes.isEmpty()) View.VISIBLE else View.GONE - if (currentSection == NoteSection.NOTES) { - val pinnedNotes = visibleNotes.filter { it.pinned } - val otherNotes = visibleNotes.filterNot { it.pinned } - if (pinnedNotes.isNotEmpty()) { - grid.addView(createSectionHeader(getString(R.string.pinned_section)), sectionHeaderLayoutParams()) - pinnedNotes.forEach { note -> - grid.addView(createNoteCard(note), noteCardLayoutParams()) - } - } - if (otherNotes.isNotEmpty()) { - if (pinnedNotes.isNotEmpty()) { - grid.addView(createSectionHeader(getString(R.string.other_notes_section)), sectionHeaderLayoutParams()) - } - otherNotes.forEach { note -> - grid.addView(createNoteCard(note), noteCardLayoutParams()) - } - } - } else { - visibleNotes.forEach { note -> - grid.addView(createNoteCard(note), noteCardLayoutParams()) - } - } - } - - private fun createNoteCard(note: Note): View { - return MaterialCardView(this).apply { - radius = dp(8).toFloat() - strokeWidth = dp(1) - strokeColor = Color.rgb(218, 220, 224) - setCardBackgroundColor(note.color) - isClickable = true - isFocusable = true - setOnClickListener { showEditor(note) } - setOnLongClickListener { - startDragAndDrop( - android.content.ClipData.newPlainText(NOTE_DRAG_LABEL, note.id), - View.DragShadowBuilder(this), - note.id, - 0 - ) - true - } - setOnDragListener { _, event -> - when (event.action) { - DragEvent.ACTION_DRAG_ENTERED -> { - strokeColor = Color.rgb(251, 188, 4) - true - } - DragEvent.ACTION_DRAG_EXITED -> { - strokeColor = Color.rgb(218, 220, 224) - true - } - DragEvent.ACTION_DROP -> { - strokeColor = Color.rgb(218, 220, 224) - val draggedId = event.localState as? String - if (draggedId != null && draggedId != note.id) { - reorderNote(draggedId, note.id) - } - true - } - DragEvent.ACTION_DRAG_ENDED -> { - strokeColor = Color.rgb(218, 220, 224) - true - } - else -> true - } - } - - val body = LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(14), dp(12), dp(14), dp(12)) - } - - if (note.images.isNotEmpty()) { - note.images.take(2).forEach { image -> - body.addView(createImagePreview(image, removable = false), LinearLayout.LayoutParams.MATCH_PARENT, dp(132)) - } - if (note.images.size > 2) { - body.addView(TextView(this@MainActivity).apply { - text = getString(R.string.more_images, note.images.size - 2) - textSize = 13f - setTextColor(Color.rgb(95, 99, 104)) - setPadding(0, dp(6), 0, 0) - }) - } - } - - if (note.title.isNotBlank()) { - body.addView(TextView(this@MainActivity).apply { - text = note.title - textSize = 16f - setTypeface(typeface, Typeface.BOLD) - setTextColor(Color.rgb(32, 33, 36)) - maxLines = 3 - }) - } - - if (note.checklist) { - val orderedItems = orderedChecklistItems(note.checklistItems) - val previewItems = orderedItems.take(8) - previewItems.forEach { item -> - body.addView(createChecklistPreviewRow(note, item)) - } - if (note.checklistItems.size > previewItems.size) { - body.addView(TextView(this@MainActivity).apply { - text = getString(R.string.more_list_items, note.checklistItems.size - previewItems.size) - textSize = 13f - setTextColor(Color.rgb(95, 99, 104)) - setPadding(0, dp(6), 0, 0) - }) - } - } else if (note.body.isNotBlank()) { - body.addView(TextView(this@MainActivity).apply { - text = note.formattedBody() - textSize = 14f - setTextColor(Color.rgb(32, 33, 36)) - maxLines = 8 - setPadding(0, if (note.title.isBlank()) 0 else dp(8), 0, 0) - }) - } - - if (note.labels.isNotEmpty()) { - val labelsRow = LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.START or Gravity.CENTER_VERTICAL - setPadding(0, dp(10), 0, 0) - } - note.labels.take(3).forEach { label -> - labelsRow.addView(createLabelChip(label)) - } - body.addView(labelsRow, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - } - - if (note.collaborators.isNotEmpty()) { - val collaboratorsRow = LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.START or Gravity.CENTER_VERTICAL - setPadding(0, dp(8), 0, 0) - } - note.collaborators.take(3).forEach { collaborator -> - collaboratorsRow.addView(createCollaboratorChip(collaborator)) - } - body.addView(collaboratorsRow, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - } - - if (note.audios.isNotEmpty()) { - note.audios.take(2).forEach { audio -> - body.addView(createAudioChip(audio, removable = false), LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - } - } - - note.reminderAt?.let { reminderAt -> - body.addView(createReminderChip(reminderAt), LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT) - } - - body.addView(TextView(this@MainActivity).apply { - text = dateFormat.format(Date(note.updatedAt)) - textSize = 11f - setTextColor(Color.rgb(95, 99, 104)) - setPadding(0, dp(12), 0, 0) - }) - - val actions = LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.END or Gravity.CENTER_VERTICAL - setPadding(0, dp(8), 0, 0) - } - if (currentSection != NoteSection.TRASH) { - actions.addView(noteActionButton( - iconRes = R.drawable.ic_share_24, - description = getString(R.string.send_note) - ) { - shareNote(note) - }) - - actions.addView(noteActionButton( - iconRes = R.drawable.ic_push_pin_24, - description = if (note.pinned) getString(R.string.unpin_note) else getString(R.string.pin_note), - tint = if (note.pinned) Color.rgb(32, 33, 36) else Color.rgb(95, 99, 104) - ) { - saveNoteState(note.copy(pinned = !note.pinned, updatedAt = System.currentTimeMillis())) - }) - - val archived = currentSection == NoteSection.ARCHIVE - actions.addView(noteActionButton( - iconRes = if (archived) R.drawable.ic_restore_24 else R.drawable.ic_archive_24, - description = if (archived) getString(R.string.restore_note) else getString(R.string.archive_note) - ) { - saveNoteState(note.copy(archived = !archived, deleted = false, updatedAt = System.currentTimeMillis())) - }) - - actions.addView(noteActionButton( - iconRes = R.drawable.ic_delete_24, - description = getString(R.string.move_to_trash) - ) { - saveNoteState(note.copy(archived = false, deleted = true, updatedAt = System.currentTimeMillis())) - }) - } else { - actions.addView(noteActionButton( - iconRes = R.drawable.ic_restore_24, - description = getString(R.string.restore_note) - ) { - saveNoteState(note.copy(archived = false, deleted = false, updatedAt = System.currentTimeMillis())) - }) - actions.addView(noteActionButton( - iconRes = R.drawable.ic_delete_24, - description = getString(R.string.delete_forever) - ) { - deleteNotePermanently(note.id) - }) - } - body.addView(actions, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - addView(body) - } - } - - private fun shareNote(note: Note) { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, note.title.ifBlank { getString(R.string.app_name) }) - putExtra(Intent.EXTRA_TEXT, note.shareText()) - } - try { - startActivity(Intent.createChooser(intent, getString(R.string.send_note))) - } catch (_: ActivityNotFoundException) { - Toast.makeText(this, getString(R.string.share_note_failed), Toast.LENGTH_SHORT).show() - } - } - - private fun Note.shareText(): String { - val lines = mutableListOf() - if (title.isNotBlank()) { - lines += title - } - if (checklist) { - checklistItems.forEach { item -> - val marker = if (item.checked) "[x]" else "[ ]" - lines += "$marker ${item.text}" - } - } else if (body.isNotBlank()) { - lines += body - } - if (labels.isNotEmpty()) { - lines += labels.joinToString(" ") { label -> "#$label" } - } - reminderAt?.let { reminder -> - lines += getString(R.string.share_reminder, dateFormat.format(Date(reminder))) - } - if (images.isNotEmpty()) { - lines += getString(R.string.share_images_count, images.size) - } - if (audios.isNotEmpty()) { - lines += getString(R.string.share_audio_count, audios.size) - } - return lines.joinToString("\n").ifBlank { getString(R.string.app_name) } - } - - private fun createSectionHeader(title: String): View { - return TextView(this).apply { - text = title.uppercase(Locale.getDefault()) - textSize = 11f - setTypeface(typeface, Typeface.BOLD) - setTextColor(Color.rgb(95, 99, 104)) - setPadding(dp(4), dp(14), dp(4), dp(8)) - } - } - - private fun createImagePreview( - attachment: ImageAttachment, - removable: Boolean, - onRemove: (() -> Unit)? = null - ): View { - return FrameLayout(this).apply { - background = roundedDrawable(Color.rgb(241, 243, 244), dp(8).toFloat()) - setPadding(0, 0, 0, 0) - ImageView(this@MainActivity).apply { - scaleType = ImageView.ScaleType.CENTER_CROP - attachment.toBitmap()?.let { bitmap -> setImageBitmap(bitmap) } - background = roundedDrawable(Color.rgb(241, 243, 244), dp(8).toFloat()) - addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - } - if (removable && onRemove != null) { - addView(ImageButton(this@MainActivity).apply { - setImageResource(R.drawable.ic_delete_24) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - background = roundedDrawable(Color.argb(220, 255, 255, 255), dp(18).toFloat()) - contentDescription = getString(R.string.remove_image) - setOnClickListener { onRemove() } - }, FrameLayout.LayoutParams(dp(36), dp(36), Gravity.TOP or Gravity.END).apply { - setMargins(0, dp(6), dp(6), 0) - }) - } - } - } - - private fun createChecklistPreviewRow(note: Note, item: ChecklistItem): View { - return CheckBox(this).apply { - text = item.text.ifBlank { getString(R.string.empty_checklist_item) } - textSize = 14f - setTextColor(Color.rgb(32, 33, 36)) - buttonTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - setPadding(0, if (note.title.isBlank()) 0 else dp(4), 0, 0) - paintFlags = if (item.checked) { - paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } else { - paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } - isChecked = item.checked - setOnCheckedChangeListener { _, checked -> - val updatedItems = note.checklistItems.map { current -> - if (current.id == item.id) current.copy(checked = checked) else current - } - saveNoteState(note.copy( - checklistItems = orderedChecklistItems(updatedItems), - updatedAt = System.currentTimeMillis() - )) - } - } - } - - private fun orderedChecklistItems(items: List): List { - if (!keepSettings.moveCheckedItemsToBottom()) return items - return items - .withIndex() - .sortedWith(compareBy> { it.value.checked }.thenBy { it.index }) - .map { it.value } - } - - private fun createCollaboratorChip(collaborator: String): View { - return TextView(this).apply { - text = getString(R.string.collaborator_chip, collaborator) - textSize = 11f - setTextColor(Color.rgb(60, 64, 67)) - maxLines = 1 - setPadding(dp(8), dp(3), dp(8), dp(3)) - background = roundedDrawable( - color = Color.argb(40, 66, 133, 244), - radius = dp(12).toFloat(), - strokeColor = Color.argb(90, 66, 133, 244), - strokeWidth = dp(1) - ) - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 0, dp(6), 0) - } - } - } - - private fun createLabelChip(label: String): View { - return TextView(this).apply { - text = label - textSize = 11f - setTextColor(Color.rgb(60, 64, 67)) - maxLines = 1 - setPadding(dp(8), dp(3), dp(8), dp(3)) - background = roundedDrawable( - color = Color.argb(36, 60, 64, 67), - radius = dp(12).toFloat(), - strokeColor = Color.argb(80, 60, 64, 67), - strokeWidth = dp(1) - ) - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 0, dp(6), 0) - } - } - } - - private fun createReminderChip(reminderAt: Long): View { - return TextView(this).apply { - text = getString(R.string.reminder_chip, dateFormat.format(Date(reminderAt))) - textSize = 11f - setTextColor(Color.rgb(60, 64, 67)) - maxLines = 1 - setPadding(dp(8), dp(3), dp(8), dp(3)) - background = roundedDrawable( - color = Color.argb(36, 251, 188, 4), - radius = dp(12).toFloat(), - strokeColor = Color.argb(120, 251, 188, 4), - strokeWidth = dp(1) - ) - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, dp(8), 0, 0) - } - } - } - - private fun createAudioChip( - attachment: AudioAttachment, - removable: Boolean, - onRemove: (() -> Unit)? = null - ): View { - return LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(4), dp(8), dp(4)) - background = roundedDrawable( - color = Color.rgb(241, 243, 244), - radius = dp(12).toFloat(), - strokeColor = Color.rgb(218, 220, 224), - strokeWidth = dp(1) - ) - addView(ImageButton(this@MainActivity).apply { - setImageResource(R.drawable.ic_play_24) - imageTintList = ColorStateList.valueOf(Color.rgb(32, 33, 36)) - background = roundedDrawable(Color.TRANSPARENT, dp(18).toFloat()) - contentDescription = getString(R.string.play_audio) - setOnClickListener { playAudio(attachment) } - }, LinearLayout.LayoutParams(dp(36), dp(36))) - addView(TextView(this@MainActivity).apply { - text = getString(R.string.audio_chip, attachment.durationMillis / 1000L) - textSize = 13f - setTextColor(Color.rgb(60, 64, 67)) - setPadding(dp(8), 0, 0, 0) - }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) - if (removable && onRemove != null) { - addView(ImageButton(this@MainActivity).apply { - setImageResource(R.drawable.ic_delete_24) - imageTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.TRANSPARENT, dp(18).toFloat()) - contentDescription = getString(R.string.remove_audio) - setOnClickListener { onRemove() } - }, LinearLayout.LayoutParams(dp(36), dp(36))) - } - } - } - - private fun playAudio(attachment: AudioAttachment) { - lifecycleScope.launch { - val file = runCatching { - withContext(Dispatchers.IO) { attachment.toTempFile(applicationContext) } - }.getOrNull() - if (file == null) { - Toast.makeText(this@MainActivity, getString(R.string.audio_play_failed), Toast.LENGTH_SHORT).show() - return@launch - } - runCatching { - MediaPlayer().apply { - setDataSource(file.absolutePath) - setOnCompletionListener { player -> - player.release() - file.delete() - } - prepare() - start() - } - }.onFailure { - file.delete() - Toast.makeText(this@MainActivity, getString(R.string.audio_play_failed), Toast.LENGTH_SHORT).show() - } - } - } - - private fun noteActionButton( - iconRes: Int, - description: String, - tint: Int = Color.rgb(95, 99, 104), - action: () -> Unit - ): ImageButton { - return ImageButton(this).apply { - setImageResource(iconRes) - imageTintList = ColorStateList.valueOf(tint) - background = roundedDrawable(Color.TRANSPARENT, dp(18).toFloat()) - contentDescription = description - setOnClickListener { action() } - layoutParams = LinearLayout.LayoutParams(dp(36), dp(36)) - } - } - - private fun saveNoteState(note: Note) { - lifecycleScope.launch { - val synced = repository.save(note) - notes = repository.readLocalNotes() - ReminderScheduler(applicationContext).schedule(note) - syncStatusLabel = if (synced) getString(R.string.synced) else getString(R.string.local_mode) - renderNotes() - } - } - - private fun deleteNotePermanently(id: String) { - lifecycleScope.launch { - val synced = repository.delete(id) - notes = repository.readLocalNotes() - ReminderScheduler(applicationContext).cancel(id) - syncStatusLabel = if (synced) getString(R.string.synced) else getString(R.string.local_mode) - renderNotes() - } - } - - private fun reorderNote(draggedId: String, targetId: String) { - val visible = notes - .filter { note -> note.belongsToCurrentView() } - .sortedWith(noteOrderComparator()) - .toMutableList() - val fromIndex = visible.indexOfFirst { it.id == draggedId } - val toIndex = visible.indexOfFirst { it.id == targetId } - if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) return - val moved = visible.removeAt(fromIndex) - visible.add(toIndex, moved) - val baseOrder = System.currentTimeMillis() - val reorderedById = visible.mapIndexed { index, note -> - note.copy(sortOrder = baseOrder - index) - }.associateBy { it.id } - notes = notes.map { note -> reorderedById[note.id] ?: note } - renderNotes() - lifecycleScope.launch { - val changed = reorderedById.values.toList() - val synced = repository.saveAll(changed) - notes = repository.readLocalNotes() - syncStatusLabel = if (synced) getString(R.string.synced) else getString(R.string.local_mode) - renderNotes() - } - } - - private fun createImageNote(attachment: ImageAttachment) { - currentSection = NoteSection.NOTES - currentLabel = null - val now = System.currentTimeMillis() - saveNoteState( - Note( - id = UUID.randomUUID().toString(), - title = "", - body = "", - color = NOTE_COLORS[1], - updatedAt = now, - sortOrder = now, - images = listOf(attachment) - ) - ) - } - - private fun createAudioNote(attachment: AudioAttachment) { - currentSection = NoteSection.NOTES - currentLabel = null - val now = System.currentTimeMillis() - saveNoteState( - Note( - id = UUID.randomUUID().toString(), - title = getString(R.string.voice_note_title), - body = "", - color = NOTE_COLORS[1], - updatedAt = now, - sortOrder = now, - audios = listOf(attachment) - ) - ) - } - - private fun showAudioRecorderDialog(onSaved: (AudioAttachment) -> Unit) { - if (!ensureAudioPermission()) return - var recorder: MediaRecorder? = null - var recordFile: File? = null - var startedAt = 0L - var recordedAttachment: AudioAttachment? = null - - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(4), 0, dp(4), 0) - } - val status = TextView(this).apply { - text = getString(R.string.audio_ready) - textSize = 15f - setTextColor(Color.rgb(60, 64, 67)) - gravity = Gravity.CENTER - setPadding(0, dp(12), 0, dp(12)) - } - body.addView(status, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - val recordButton = MaterialButton(this).apply { - text = getString(R.string.start_recording) - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(251, 188, 4)) - cornerRadius = dp(20) - } - body.addView(recordButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(48)) - - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.new_voice_note) - .setView(body) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.save, null) - .create() - - fun stopRecording() { - val activeRecorder = recorder ?: return - runCatching { - activeRecorder.stop() - } - activeRecorder.release() - recorder = null - val file = recordFile ?: return - val duration = (System.currentTimeMillis() - startedAt).coerceAtLeast(0L) - recordedAttachment = AudioAttachment.fromFile(file, duration) - file.delete() - status.text = getString(R.string.audio_recorded, duration / 1000L) - recordButton.text = getString(R.string.start_recording) - } - - recordButton.setOnClickListener { - if (recorder == null) { - recordFile = File(cacheDir, "qkeep-audio-${UUID.randomUUID()}.m4a") - val output = recordFile ?: return@setOnClickListener - val mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(this) - } else { - @Suppress("DEPRECATION") - MediaRecorder() - } - runCatching { - mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - mediaRecorder.setOutputFile(output.absolutePath) - mediaRecorder.prepare() - mediaRecorder.start() - startedAt = System.currentTimeMillis() - recorder = mediaRecorder - status.text = getString(R.string.audio_recording) - recordButton.text = getString(R.string.stop_recording) - }.onFailure { - mediaRecorder.release() - output.delete() - Toast.makeText(this, getString(R.string.audio_record_failed), Toast.LENGTH_SHORT).show() - } - } else { - stopRecording() - } - } - - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - if (recorder != null) { - stopRecording() - } - val attachment = recordedAttachment - if (attachment == null) { - Toast.makeText(this, getString(R.string.audio_empty), Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - onSaved(attachment) - dialog.dismiss() - } - } - dialog.setOnDismissListener { - if (recorder != null) { - stopRecording() - } - } - dialog.show() - } - - private fun showDrawingDialog(onSaved: (ImageAttachment) -> Unit) { - val drawingView = DrawingView(this).apply { - background = roundedDrawable( - color = Color.WHITE, - radius = dp(8).toFloat(), - strokeColor = Color.rgb(218, 220, 224), - strokeWidth = dp(1) - ) - } - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(4), 0, dp(4), 0) - addView(drawingView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(360))) - } - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.new_drawing_note) - .setView(body) - .setNegativeButton(R.string.cancel, null) - .setNeutralButton(R.string.clear_drawing, null) - .setPositiveButton(R.string.save_drawing, null) - .create() - - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { - drawingView.clear() - } - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - if (!drawingView.hasInk) { - Toast.makeText(this, getString(R.string.empty_drawing), Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - onSaved(drawingView.toAttachment()) - dialog.dismiss() - } - } - dialog.show() - } - - private fun createBodyFormatToolbar(bodyInput: EditText): View { - return HorizontalScrollView(this).apply { - isHorizontalScrollBarEnabled = false - overScrollMode = View.OVER_SCROLL_NEVER - val row = LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(8), 0, dp(4)) - } - row.addView(formatButton("B", R.string.format_bold) { - applyBodyFormat(bodyInput, BodyTextStyle.BOLD) - }) - row.addView(formatButton("I", R.string.format_italic) { - applyBodyFormat(bodyInput, BodyTextStyle.ITALIC) - }) - row.addView(formatButton("U", R.string.format_underline) { - applyBodyFormat(bodyInput, BodyTextStyle.UNDERLINE) - }) - row.addView(formatButton("H1", R.string.format_h1) { - applyBodyFormat(bodyInput, BodyTextStyle.H1) - }) - row.addView(formatButton("H2", R.string.format_h2) { - applyBodyFormat(bodyInput, BodyTextStyle.H2) - }) - row.addView(formatButton("T", R.string.format_normal) { - clearBodyFormat(bodyInput) - }) - addView(row, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - } - } - - private fun formatButton(label: String, descriptionRes: Int, onClick: () -> Unit): TextView { - return TextView(this).apply { - text = label - textSize = 14f - setTypeface(typeface, Typeface.BOLD) - gravity = Gravity.CENTER - minWidth = dp(44) - setTextColor(Color.rgb(32, 33, 36)) - contentDescription = getString(descriptionRes) - background = roundedDrawable( - color = Color.rgb(241, 243, 244), - radius = dp(18).toFloat(), - strokeColor = Color.rgb(218, 220, 224), - strokeWidth = dp(1) - ) - setOnClickListener { onClick() } - }.also { button -> - button.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(36)).apply { - setMargins(0, 0, dp(8), 0) - } - } - } - - private fun applyBodyFormat(bodyInput: EditText, style: BodyTextStyle) { - val editable = bodyInput.text ?: return - val range = selectedFormatRange(bodyInput, paragraph = style == BodyTextStyle.H1 || style == BodyTextStyle.H2) - if (range == null) { - Toast.makeText(this, getString(R.string.format_select_text), Toast.LENGTH_SHORT).show() - return - } - val endExclusive = range.last + 1 - - if (style == BodyTextStyle.H1 || style == BodyTextStyle.H2) { - editable.removeBodyFormats(range.first, endExclusive, setOf(BodyTextStyle.H1, BodyTextStyle.H2)) - } - - val hadStyle = editable.hasBodyFormat(range.first, endExclusive, style) - editable.removeBodyFormats(range.first, endExclusive, setOf(style)) - if (!hadStyle) { - editable.setSpan( - BodyFormatMarker(style), - range.first, - endExclusive, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - editable.rebuildBodyFormatVisualSpans() - } - - private fun clearBodyFormat(bodyInput: EditText) { - val editable = bodyInput.text ?: return - val range = selectedFormatRange(bodyInput, paragraph = false) - if (range == null) { - Toast.makeText(this, getString(R.string.format_select_text), Toast.LENGTH_SHORT).show() - return - } - editable.removeBodyFormats(range.first, range.last + 1, BodyTextStyle.values().toSet()) - editable.rebuildBodyFormatVisualSpans() - } - - private fun selectedFormatRange(bodyInput: EditText, paragraph: Boolean): IntRange? { - val editable = bodyInput.text ?: return null - if (editable.isEmpty()) return null - val rawStart = bodyInput.selectionStart.coerceIn(0, editable.length) - val rawEnd = bodyInput.selectionEnd.coerceIn(0, editable.length) - var start = minOf(rawStart, rawEnd) - var end = maxOf(rawStart, rawEnd) - - if (paragraph) { - while (start > 0 && editable[start - 1] != '\n') start-- - while (end < editable.length && editable[end] != '\n') end++ - } - - if (start == end) return null - return start until end - } - - private fun showEditor(note: Note?, startChecklist: Boolean = false) { - var selectedColor = note?.color ?: NOTE_COLORS.first() - var checklistMode = note?.checklist ?: startChecklist - var selectedReminderAt = note?.reminderAt - val imageAttachments = note?.images.orEmpty().toMutableList() - val audioAttachments = note?.audios.orEmpty().toMutableList() - data class ChecklistEditorRow( - val id: String, - val checkBox: CheckBox, - val input: EditText - ) - val editorRows = mutableListOf() - - val form = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(4), 0, dp(4), 0) - } - - val titleInput = EditText(this).apply { - hint = getString(R.string.note_title_hint) - setText(note?.title.orEmpty()) - setSingleLine(true) - textSize = 20f - background = null - } - form.addView(titleInput, LinearLayout.LayoutParams.MATCH_PARENT, dp(56)) - - val imagesPanel = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - } - fun renderImagesPanel() { - imagesPanel.removeAllViews() - imageAttachments.forEach { attachment -> - imagesPanel.addView(createImagePreview( - attachment = attachment, - removable = true - ) { - imageAttachments.remove(attachment) - renderImagesPanel() - }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(160)).apply { - setMargins(0, 0, 0, dp(8)) - }) - } - } - renderImagesPanel() - form.addView(imagesPanel, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val addImageButton = MaterialButton(this).apply { - text = getString(R.string.add_image) - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(241, 243, 244)) - cornerRadius = dp(20) - setOnClickListener { - pickImage { attachment -> - imageAttachments += attachment - renderImagesPanel() - } - } - } - form.addView(addImageButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - - val addDrawingButton = MaterialButton(this).apply { - text = getString(R.string.add_drawing) - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(241, 243, 244)) - cornerRadius = dp(20) - setOnClickListener { - showDrawingDialog { attachment -> - imageAttachments += attachment - renderImagesPanel() - } - } - } - form.addView(addDrawingButton, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { - setMargins(0, dp(8), 0, 0) - }) - - val audiosPanel = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, dp(8), 0, 0) - } - fun renderAudiosPanel() { - audiosPanel.removeAllViews() - audioAttachments.forEach { attachment -> - audiosPanel.addView(createAudioChip( - attachment = attachment, - removable = true - ) { - audioAttachments.remove(attachment) - renderAudiosPanel() - }, LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - } - } - renderAudiosPanel() - form.addView(audiosPanel, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val addAudioButton = MaterialButton(this).apply { - text = getString(R.string.add_audio) - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(241, 243, 244)) - cornerRadius = dp(20) - setOnClickListener { - showAudioRecorderDialog { attachment -> - audioAttachments += attachment - renderAudiosPanel() - } - } - } - form.addView(addAudioButton, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { - setMargins(0, dp(8), 0, 0) - }) - - val modeButton = MaterialButton(this).apply { - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(241, 243, 244)) - cornerRadius = dp(20) - } - form.addView(modeButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - - val bodyInput = EditText(this).apply { - hint = getString(R.string.note_body_hint) - setText(note?.formattedBody() ?: SpannableStringBuilder("")) - gravity = Gravity.TOP - minLines = 6 - textSize = 16f - background = null - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - } - form.addView(createBodyFormatToolbar(bodyInput), LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - form.addView(bodyInput, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - val labelsInput = EditText(this).apply { - hint = getString(R.string.labels_hint) - setText(note?.labels.orEmpty().joinToString(", ")) - setSingleLine(true) - textSize = 15f - setTextColor(Color.rgb(32, 33, 36)) - setHintTextColor(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.rgb(241, 243, 244), dp(12).toFloat()) - setPadding(dp(14), 0, dp(14), 0) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - } - form.addView(labelsInput, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(48)).apply { - setMargins(0, dp(10), 0, 0) - }) - - val collaboratorsInput = EditText(this).apply { - hint = getString(R.string.collaborators_hint) - setText(note?.collaborators.orEmpty().joinToString(", ")) - setSingleLine(true) - textSize = 15f - setTextColor(Color.rgb(32, 33, 36)) - setHintTextColor(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.rgb(241, 243, 244), dp(12).toFloat()) - setPadding(dp(14), 0, dp(14), 0) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - } - form.addView(collaboratorsInput, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(48)).apply { - setMargins(0, dp(10), 0, 0) - }) - - val reminderRow = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(10), 0, 0) - } - val reminderButton = MaterialButton(this).apply { - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(241, 243, 244)) - cornerRadius = dp(20) - } - val clearReminderButton = MaterialButton(this).apply { - text = getString(R.string.clear_reminder) - setAllCaps(false) - setTextColor(Color.rgb(95, 99, 104)) - backgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT) - cornerRadius = dp(20) - } - fun renderReminderControls() { - reminderButton.text = selectedReminderAt?.let { reminderAt -> - getString(R.string.reminder_set, dateFormat.format(Date(reminderAt))) - } ?: getString(R.string.add_reminder) - clearReminderButton.visibility = if (selectedReminderAt == null) View.GONE else View.VISIBLE - } - reminderButton.setOnClickListener { - showReminderPicker(selectedReminderAt ?: System.currentTimeMillis() + 60 * 60 * 1000L) { reminderAt -> - selectedReminderAt = reminderAt - renderReminderControls() - } - } - clearReminderButton.setOnClickListener { - selectedReminderAt = null - renderReminderControls() - } - reminderRow.addView(reminderButton, LinearLayout.LayoutParams(0, dp(44), 1f)) - reminderRow.addView(clearReminderButton, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dp(44)).apply { - setMargins(dp(8), 0, 0, 0) - }) - form.addView(reminderRow, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - renderReminderControls() - - val checklistRows = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, dp(8), 0, 0) - } - val addItemButton = MaterialButton(this).apply { - text = getString(R.string.add_list_item) - setAllCaps(false) - setTextColor(Color.rgb(32, 33, 36)) - backgroundTintList = ColorStateList.valueOf(Color.rgb(241, 243, 244)) - cornerRadius = dp(20) - } - val checklistPanel = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView(checklistRows, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - addView(addItemButton, LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) - } - form.addView(checklistPanel, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - fun addChecklistRow(item: ChecklistItem = ChecklistItem(UUID.randomUUID().toString(), ""), insertAtTop: Boolean = false) { - lateinit var editorRow: ChecklistEditorRow - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, 0, 0, dp(4)) - } - val checkBox = CheckBox(this).apply { - buttonTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - isChecked = item.checked - } - row.addView(checkBox, LinearLayout.LayoutParams(dp(48), dp(48))) - val itemInput = EditText(this).apply { - hint = getString(R.string.list_item_hint) - setText(item.text) - textSize = 16f - setSingleLine(true) - background = null - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - } - row.addView(itemInput, LinearLayout.LayoutParams(0, dp(48), 1f)) - val removeButton = ImageButton(this).apply { - setImageResource(R.drawable.ic_delete_24) - imageTintList = ColorStateList.valueOf(Color.rgb(95, 99, 104)) - background = roundedDrawable(Color.TRANSPARENT, dp(18).toFloat()) - contentDescription = getString(R.string.remove_list_item) - setOnClickListener { - checklistRows.removeView(row) - editorRows.remove(editorRow) - } - } - row.addView(removeButton, LinearLayout.LayoutParams(dp(40), dp(40))) - editorRow = ChecklistEditorRow( - id = item.id.ifBlank { UUID.randomUUID().toString() }, - checkBox = checkBox, - input = itemInput - ) - if (insertAtTop) { - editorRows.add(0, editorRow) - checklistRows.addView(row, 0, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)) - } else { - editorRows += editorRow - checklistRows.addView(row, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - } - } - - fun checklistRowsFromBody(): List { - return bodyInput.text?.toString() - .orEmpty() - .lines() - .map { it.trim() } - .filter { it.isNotBlank() } - .map { text -> ChecklistItem(UUID.randomUUID().toString(), text) } - } - - fun updateModeVisibility() { - modeButton.text = getString(if (checklistMode) R.string.text_note_mode else R.string.checklist_mode) - bodyInput.visibility = if (checklistMode) View.GONE else View.VISIBLE - checklistPanel.visibility = if (checklistMode) View.VISIBLE else View.GONE - if (checklistMode && editorRows.isEmpty()) { - val converted = checklistRowsFromBody() - if (converted.isEmpty()) { - addChecklistRow() - } else { - converted.forEach { item -> addChecklistRow(item) } - } - } - } - - orderedChecklistItems(note?.checklistItems.orEmpty()).forEach { item -> addChecklistRow(item) } - addItemButton.setOnClickListener { - addChecklistRow(insertAtTop = !keepSettings.addNewListItemsToBottom()) - } - modeButton.setOnClickListener { - if (checklistMode) { - bodyInput.setText( - editorRows - .map { row -> row.input.text?.toString().orEmpty().trim() } - .filter { it.isNotBlank() } - .joinToString("\n") - ) - checklistRows.removeAllViews() - editorRows.clear() - } - checklistMode = !checklistMode - updateModeVisibility() - } - updateModeVisibility() - - val swatches = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(12), 0, 0) - } - form.addView(swatches, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) - - fun renderSwatches() { - swatches.removeAllViews() - NOTE_COLORS.forEach { color -> - swatches.addView(View(this).apply { - background = roundedDrawable( - color = color, - radius = dp(18).toFloat(), - strokeColor = if (color == selectedColor) Color.rgb(32, 33, 36) else Color.rgb(218, 220, 224), - strokeWidth = if (color == selectedColor) dp(2) else dp(1) - ) - setOnClickListener { - selectedColor = color - renderSwatches() - } - }, LinearLayout.LayoutParams(dp(36), dp(36)).apply { - setMargins(0, 0, dp(10), 0) - }) - } - } - renderSwatches() - - val dialog = AlertDialog.Builder(this) - .setTitle(if (note == null) R.string.new_note else R.string.edit_note) - .setView(ScrollView(this).apply { - addView(form) - }) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.save, null) - .apply { - if (note != null) { - setNeutralButton(if (note.deleted) R.string.delete_forever else R.string.delete, null) - } - } - .create() - - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val title = titleInput.text?.toString().orEmpty().trim() - val checklistItems = if (checklistMode) { - val rawChecklistItems = editorRows.mapNotNull { row -> - val text = row.input.text?.toString().orEmpty().trim() - text.takeIf { it.isNotBlank() }?.let { - ChecklistItem( - id = row.id.ifBlank { UUID.randomUUID().toString() }, - text = it, - checked = row.checkBox.isChecked - ) - } - } - orderedChecklistItems(rawChecklistItems) - } else { - emptyList() - } - val rawBodyText = bodyInput.text?.toString().orEmpty() - val body = if (checklistMode) "" else rawBodyText.trim() - val bodyTrimStart = rawBodyText.indexOfFirst { !it.isWhitespace() }.takeIf { it >= 0 } ?: 0 - val bodyFormats = if (checklistMode || body.isBlank()) { - emptyList() - } else { - bodyInput.text.bodyTextFormats(bodyTrimStart, bodyTrimStart + body.length) - } - if (title.isBlank() && body.isBlank() && checklistItems.isEmpty() && imageAttachments.isEmpty() && audioAttachments.isEmpty()) { - dialog.dismiss() - return@setOnClickListener - } - val labels = labelsInput.text?.toString().orEmpty().toLabels() - val collaborators = collaboratorsInput.text?.toString().orEmpty().toCollaborators() - val now = System.currentTimeMillis() - - val savedNote = Note( - id = note?.id ?: UUID.randomUUID().toString(), - title = title, - body = body, - bodyFormats = bodyFormats, - color = selectedColor, - updatedAt = now, - sortOrder = note?.sortOrder ?: now, - pinned = note?.pinned ?: false, - archived = note?.archived ?: false, - deleted = note?.deleted ?: false, - checklist = checklistMode, - checklistItems = checklistItems, - labels = labels, - collaborators = collaborators, - reminderAt = selectedReminderAt, - images = imageAttachments.toList(), - audios = audioAttachments.toList() - ) - - lifecycleScope.launch { - val synced = repository.save(savedNote) - notes = repository.readLocalNotes() - ReminderScheduler(applicationContext).schedule(savedNote) - syncStatusLabel = if (synced) getString(R.string.synced) else getString(R.string.local_mode) - renderNotes() - dialog.dismiss() - } - } - - if (note != null) { - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { - lifecycleScope.launch { - val synced = if (note.deleted) { - repository.delete(note.id) - } else { - repository.save(note.copy(archived = false, deleted = true, updatedAt = System.currentTimeMillis())) - } - notes = repository.readLocalNotes() - ReminderScheduler(applicationContext).cancel(note.id) - syncStatusLabel = if (synced) getString(R.string.synced) else getString(R.string.local_mode) - renderNotes() - dialog.dismiss() - } - } - } - } - - dialog.setOnDismissListener { - currentFocus?.let { view -> - val manager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - manager.hideSoftInputFromWindow(view.windowToken, 0) - } - } - dialog.show() - titleInput.requestFocus() - } - - private fun showReminderPicker(initialMillis: Long, onSelected: (Long) -> Unit) { - val calendar = Calendar.getInstance().apply { - timeInMillis = initialMillis - } - DatePickerDialog( - this, - { _, year, month, dayOfMonth -> - calendar.set(Calendar.YEAR, year) - calendar.set(Calendar.MONTH, month) - calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth) - TimePickerDialog( - this, - { _, hourOfDay, minute -> - calendar.set(Calendar.HOUR_OF_DAY, hourOfDay) - calendar.set(Calendar.MINUTE, minute) - calendar.set(Calendar.SECOND, 0) - calendar.set(Calendar.MILLISECOND, 0) - onSelected(calendar.timeInMillis) - }, - calendar.get(Calendar.HOUR_OF_DAY), - calendar.get(Calendar.MINUTE), - true - ).show() - }, - calendar.get(Calendar.YEAR), - calendar.get(Calendar.MONTH), - calendar.get(Calendar.DAY_OF_MONTH) - ).show() - } - - private fun ensureNotificationPermission() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 12) - } - - private fun ensureAudioPermission(): Boolean { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - return true - } - requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), RECORD_AUDIO_PERMISSION_REQUEST) - Toast.makeText(this, getString(R.string.audio_permission_required), Toast.LENGTH_SHORT).show() - return false - } - - private fun Note.belongsTo(section: NoteSection): Boolean { - return when (section) { - NoteSection.NOTES -> !archived && !deleted - NoteSection.REMINDERS -> !deleted && reminderAt != null - NoteSection.ARCHIVE -> archived && !deleted - NoteSection.TRASH -> deleted - } - } - - private fun Note.belongsToCurrentView(): Boolean { - val selectedLabel = currentLabel - return if (selectedLabel != null) { - !deleted && labels.any { label -> label.equals(selectedLabel, ignoreCase = true) } - } else { - belongsTo(currentSection) - } - } - - private fun Note.matchesQuery(query: String): Boolean { - if (query.isEmpty()) return true - return title.lowercase(Locale.getDefault()).contains(query) || - body.lowercase(Locale.getDefault()).contains(query) || - checklistItems.any { item -> item.text.lowercase(Locale.getDefault()).contains(query) } || - labels.any { label -> label.lowercase(Locale.getDefault()).contains(query) } || - collaborators.any { collaborator -> collaborator.lowercase(Locale.getDefault()).contains(query) } - } - - private fun Note.matchesSearchFilters(): Boolean { - val colorFilter = selectedSearchColor - if (colorFilter != null && color != colorFilter) return false - - return when (selectedSearchType) { - SearchTypeFilter.ALL -> true - SearchTypeFilter.TEXT -> !checklist && images.isEmpty() && audios.isEmpty() && (title.isNotBlank() || body.isNotBlank()) - SearchTypeFilter.LISTS -> checklist - SearchTypeFilter.IMAGES -> images.isNotEmpty() - SearchTypeFilter.DRAWINGS -> images.any { image -> image.mimeType == "image/png" } - SearchTypeFilter.RECORDINGS -> audios.isNotEmpty() - SearchTypeFilter.REMINDERS -> reminderAt != null - } - } - - private fun hasActiveSearchFilter(): Boolean { - return selectedSearchType != SearchTypeFilter.ALL || selectedSearchColor != null - } - - private fun activeSearchFilterLabel(): String? { - val parts = mutableListOf() - if (selectedSearchType != SearchTypeFilter.ALL) { - parts += getString(selectedSearchType.labelRes) - } - selectedSearchColor?.let { - parts += getString(R.string.filter_color_active) - } - return parts.takeIf { it.isNotEmpty() }?.joinToString(" + ") - } - - private fun availableLabels(): List { - return notes - .asSequence() - .filterNot { it.deleted } - .flatMap { it.labels.asSequence() } - .map { it.trim() } - .filter { it.isNotBlank() } - .distinctBy { it.lowercase(Locale.getDefault()) } - .sortedWith(String.CASE_INSENSITIVE_ORDER) - .toList() - } - - private fun noteOrderComparator(): Comparator { - return compareByDescending { it.sortOrder } - .thenByDescending { it.updatedAt } - .thenBy { it.id } - } - - private fun sectionTitleRes(section: NoteSection): Int { - return when (section) { - NoteSection.NOTES -> R.string.notes_section - NoteSection.REMINDERS -> R.string.reminders_section - NoteSection.ARCHIVE -> R.string.archive_section - NoteSection.TRASH -> R.string.trash_section - } - } - - private fun searchHintRes(section: NoteSection): Int { - return when (section) { - NoteSection.NOTES -> R.string.search_notes - NoteSection.REMINDERS -> R.string.search_reminders - NoteSection.ARCHIVE -> R.string.search_archive - NoteSection.TRASH -> R.string.search_trash - } - } - - private fun emptyTextRes(section: NoteSection): Int { - return when (section) { - NoteSection.NOTES -> R.string.empty_notes - NoteSection.REMINDERS -> R.string.empty_reminders - NoteSection.ARCHIVE -> R.string.empty_archive - NoteSection.TRASH -> R.string.empty_trash - } - } - - private fun sectionHeaderLayoutParams(): GridLayout.LayoutParams { - return GridLayout.LayoutParams().apply { - width = ViewGroup.LayoutParams.MATCH_PARENT - height = ViewGroup.LayoutParams.WRAP_CONTENT - columnSpec = GridLayout.spec(GridLayout.UNDEFINED, columnCountForScreen()) - } - } - - private fun noteCardLayoutParams(): GridLayout.LayoutParams { - val columns = columnCountForScreen() - val gap = dp(10) - val containerWidth = grid.width.takeIf { it > 0 } ?: (resources.displayMetrics.widthPixels - dp(32)) - val availableWidth = containerWidth - gap * columns - val cardWidth = availableWidth / columns - return GridLayout.LayoutParams().apply { - width = cardWidth - height = ViewGroup.LayoutParams.WRAP_CONTENT - setMargins(0, 0, gap, gap) - } - } - - private fun columnCountForScreen(): Int { - val widthDp = resources.configuration.screenWidthDp - return when { - widthDp >= 840 -> 4 - widthDp >= 600 -> 3 - else -> 2 - } - } - - private fun roundedDrawable( - color: Int, - radius: Float, - strokeColor: Int? = null, - strokeWidth: Int = 0 - ): GradientDrawable { - return GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - setColor(color) - cornerRadius = radius - if (strokeColor != null && strokeWidth > 0) { - setStroke(strokeWidth, strokeColor) - } - } - } - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private fun View.applySafeAreaInsets() { - val baseLeft = paddingLeft - val baseTop = paddingTop - val baseRight = paddingRight - val baseBottom = paddingBottom - ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val safeLeft = maxOf(systemBars.left, displayCutout.left) - val safeTop = maxOf(systemBars.top, displayCutout.top, statusBarHeight()) + dp(TOP_SAFE_AREA_EXTRA_DP) - val safeRight = maxOf(systemBars.right, displayCutout.right) - val safeBottom = maxOf(systemBars.bottom, displayCutout.bottom, navigationBarHeight()) + dp(BOTTOM_SAFE_AREA_EXTRA_DP) - view.setPadding( - baseLeft + safeLeft, - baseTop + safeTop, - baseRight + safeRight, - baseBottom + safeBottom - ) - insets - } - requestApplyInsetsWhenAttached() - } - - private fun View.requestApplyInsetsWhenAttached() { - if (isAttachedToWindow) { - ViewCompat.requestApplyInsets(this) - return - } - addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(view: View) { - view.removeOnAttachStateChangeListener(this) - ViewCompat.requestApplyInsets(view) - } - - override fun onViewDetachedFromWindow(view: View) = Unit - }) - } - - private fun statusBarHeight(): Int = systemDimensionPixelSize("status_bar_height") - - private fun navigationBarHeight(): Int = systemDimensionPixelSize("navigation_bar_height") - - private fun systemDimensionPixelSize(name: String): Int { - val id = resources.getIdentifier(name, "dimen", "android") - return if (id > 0) resources.getDimensionPixelSize(id) else 0 - } -} - -class ReminderReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - ReminderScheduler(context).reschedule(NotesDatabase(context).listNotes()) - return - } - if (intent.action != ACTION_SHOW_REMINDER) return - if (!ReminderScheduler(context).canPostNotifications()) return - - val noteId = intent.getStringExtra(EXTRA_NOTE_ID).orEmpty() - val title = intent.getStringExtra(EXTRA_NOTE_TITLE).orEmpty().ifBlank { - context.getString(R.string.reminder_notification_title) - } - val body = intent.getStringExtra(EXTRA_NOTE_BODY).orEmpty() - val openIntent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(EXTRA_NOTE_ID, noteId) - } - val contentIntent = PendingIntent.getActivity( - context, - noteId.stableRequestCode(), - openIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - val notification = NotificationCompat.Builder(context, REMINDER_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification_24) - .setContentTitle(title) - .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) - .setAutoCancel(true) - .setContentIntent(contentIntent) - .setGroup(REMINDER_NOTIFICATION_GROUP) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .build() - - val manager = context.getSystemService(NotificationManager::class.java) - manager.notify(noteId.stableRequestCode(), notification) - } -} - -class ReminderScheduler(private val context: Context) { - private val alarmManager = context.getSystemService(AlarmManager::class.java) - - fun ensureNotificationChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val manager = context.getSystemService(NotificationManager::class.java) - val existing = manager.getNotificationChannel(REMINDER_CHANNEL_ID) - if (existing != null) return - manager.createNotificationChannel( - NotificationChannel( - REMINDER_CHANNEL_ID, - context.getString(R.string.reminder_channel_name), - NotificationManager.IMPORTANCE_DEFAULT - ).apply { - description = context.getString(R.string.reminder_channel_description) - } - ) - } - - fun canPostNotifications(): Boolean { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - } - - fun reschedule(notes: List) { - ensureNotificationChannel() - notes.forEach { note -> schedule(note) } - } - - fun schedule(note: Note) { - cancel(note.id) - val reminderAt = note.reminderAt - if (reminderAt == null || reminderAt <= System.currentTimeMillis() || note.deleted) return - ensureNotificationChannel() - val operation = reminderPendingIntent(note, PendingIntent.FLAG_UPDATE_CURRENT) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, reminderAt, operation) - } else { - alarmManager.set(AlarmManager.RTC_WAKEUP, reminderAt, operation) - } - } - - fun cancel(noteId: String) { - val operation = cancelPendingIntent(noteId) - alarmManager.cancel(operation) - operation.cancel() - } - - private fun reminderPendingIntent(note: Note, flag: Int): PendingIntent { - val body = if (note.checklist) { - note.checklistItems.joinToString("\n") { item -> item.text } - } else { - note.body - } - return PendingIntent.getBroadcast( - context, - note.id.stableRequestCode(), - Intent(context, ReminderReceiver::class.java).apply { - action = ACTION_SHOW_REMINDER - putExtra(EXTRA_NOTE_ID, note.id) - putExtra(EXTRA_NOTE_TITLE, note.title) - putExtra(EXTRA_NOTE_BODY, body) - }, - flag or PendingIntent.FLAG_IMMUTABLE - ) - } - - private fun cancelPendingIntent(noteId: String): PendingIntent { - return PendingIntent.getBroadcast( - context, - noteId.stableRequestCode(), - Intent(context, ReminderReceiver::class.java).apply { - action = ACTION_SHOW_REMINDER - putExtra(EXTRA_NOTE_ID, noteId) - }, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } -} - -private fun String.stableRequestCode(): Int = hashCode() - -class DrawingView(context: Context) : View(context) { - private val committedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(32, 33, 36) - style = Paint.Style.STROKE - strokeWidth = 8f - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - } - private val livePath = Path() - private var bitmap: Bitmap? = null - private var bitmapCanvas: Canvas? = null - var hasInk: Boolean = false - private set - - override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { - super.onSizeChanged(width, height, oldWidth, oldHeight) - if (width <= 0 || height <= 0) return - bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { created -> - bitmapCanvas = Canvas(created) - } - } - - override fun onDraw(canvas: Canvas) { - canvas.drawColor(Color.WHITE) - bitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } - canvas.drawPath(livePath, committedPaint) - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - livePath.reset() - livePath.moveTo(event.x, event.y) - parent?.requestDisallowInterceptTouchEvent(true) - } - MotionEvent.ACTION_MOVE -> { - livePath.lineTo(event.x, event.y) - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - livePath.lineTo(event.x, event.y) - bitmapCanvas?.drawPath(livePath, committedPaint) - livePath.reset() - hasInk = true - parent?.requestDisallowInterceptTouchEvent(false) - } - } - invalidate() - return true - } - - fun clear() { - bitmap?.eraseColor(Color.TRANSPARENT) - livePath.reset() - hasInk = false - invalidate() - } - - fun toAttachment(): ImageAttachment { - val source = bitmap ?: Bitmap.createBitmap( - width.coerceAtLeast(1), - height.coerceAtLeast(1), - Bitmap.Config.ARGB_8888 - ) - val outputBitmap = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) - Canvas(outputBitmap).apply { - drawColor(Color.WHITE) - drawBitmap(source, 0f, 0f, null) - drawPath(livePath, committedPaint) - } - val bytes = ByteArrayOutputStream().use { output -> - outputBitmap.compress(Bitmap.CompressFormat.PNG, 100, output) - output.toByteArray() - } - return ImageAttachment( - id = UUID.randomUUID().toString(), - mimeType = "image/png", - base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - ) - } -} - -data class Note( - val id: String, - val title: String, - val body: String, - val bodyFormats: List = emptyList(), - val color: Int, - val updatedAt: Long, - val sortOrder: Long = updatedAt, - val pinned: Boolean = false, - val archived: Boolean = false, - val deleted: Boolean = false, - val checklist: Boolean = false, - val checklistItems: List = emptyList(), - val labels: List = emptyList(), - val owner: String = "", - val collaborators: List = emptyList(), - val reminderAt: Long? = null, - val images: List = emptyList(), - val audios: List = emptyList(), -) - -data class BodyTextFormat( - val start: Int, - val end: Int, - val style: BodyTextStyle, -) - -private class BodyFormatMarker(val style: BodyTextStyle) - -private fun Note.formattedBody(): SpannableStringBuilder = body.toFormattedBody(bodyFormats) - -private fun String.toFormattedBody(formats: List): SpannableStringBuilder { - val text = SpannableStringBuilder(this) - formats - .filter { it.start >= 0 && it.end <= length && it.end > it.start } - .forEach { format -> - text.setSpan( - BodyFormatMarker(format.style), - format.start, - format.end, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - text.rebuildBodyFormatVisualSpans() - return text -} - -private fun Spannable.hasBodyFormat(start: Int, end: Int, style: BodyTextStyle): Boolean { - return getSpans(start, end, BodyFormatMarker::class.java).any { marker -> - marker.style == style && getSpanStart(marker) <= start && getSpanEnd(marker) >= end - } -} - -private fun Spannable.removeBodyFormats(start: Int, end: Int, styles: Set) { - getSpans(start, end, BodyFormatMarker::class.java) - .filter { marker -> marker.style in styles } - .forEach { marker -> removeSpan(marker) } -} - -private fun Spannable.rebuildBodyFormatVisualSpans() { - getSpans(0, length, StyleSpan::class.java).forEach { removeSpan(it) } - getSpans(0, length, UnderlineSpan::class.java).forEach { removeSpan(it) } - getSpans(0, length, RelativeSizeSpan::class.java).forEach { removeSpan(it) } - - getSpans(0, length, BodyFormatMarker::class.java).forEach { marker -> - val start = getSpanStart(marker) - val end = getSpanEnd(marker) - if (start < 0 || end <= start || end > length) return@forEach - when (marker.style) { - BodyTextStyle.BOLD -> setSpan(StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - BodyTextStyle.ITALIC -> setSpan(StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - BodyTextStyle.UNDERLINE -> setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - BodyTextStyle.H1 -> { - setSpan(StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - setSpan(RelativeSizeSpan(1.35f), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - BodyTextStyle.H2 -> { - setSpan(StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - setSpan(RelativeSizeSpan(1.18f), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } - } -} - -private fun CharSequence.bodyTextFormats(trimStart: Int, trimEnd: Int): List { - val spanned = this as? Spanned ?: return emptyList() - if (trimStart < 0 || trimEnd <= trimStart) return emptyList() - return spanned.getSpans(trimStart, trimEnd, BodyFormatMarker::class.java) - .mapNotNull { marker -> - val start = spanned.getSpanStart(marker).coerceAtLeast(trimStart) - val end = spanned.getSpanEnd(marker).coerceAtMost(trimEnd) - if (end <= start) { - null - } else { - BodyTextFormat( - start = start - trimStart, - end = end - trimStart, - style = marker.style - ) - } - } - .distinct() - .sortedWith(compareBy { it.start }.thenBy { it.end }.thenBy { it.style.name }) -} - -private fun bodyTextFormatsFromJson(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return runCatching { - JSONArray(raw).toBodyTextFormats() - }.getOrDefault(emptyList()) -} - -private fun JSONArray.toBodyTextFormats(): List { - return buildList { - for (index in 0 until length()) { - val item = optJSONObject(index) ?: continue - val start = item.optInt("start", -1) - val end = item.optInt("end", -1) - val style = runCatching { - BodyTextStyle.valueOf(item.optString("style").uppercase(Locale.US)) - }.getOrNull() ?: continue - if (start >= 0 && end > start) { - add(BodyTextFormat(start = start, end = end, style = style)) - } - } - } -} - -private fun List.toBodyFormatJsonArray(): JSONArray { - return JSONArray().also { array -> - forEach { format -> - array.put(JSONObject() - .put("start", format.start) - .put("end", format.end) - .put("style", format.style.name.lowercase(Locale.US)) - ) - } - } -} - -private fun List.toBodyFormatJsonString(): String = toBodyFormatJsonArray().toString() - -data class AudioAttachment( - val id: String, - val mimeType: String, - val base64: String, - val durationMillis: Long, -) { - fun toTempFile(context: Context): File { - val file = File(context.cacheDir, "qkeep-audio-$id.m4a") - file.writeBytes(Base64.decode(base64, Base64.DEFAULT)) - return file - } - - companion object { - fun fromFile(file: File, durationMillis: Long): AudioAttachment { - val bytes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Files.readAllBytes(file.toPath()) - } else { - file.readBytes() - } - return AudioAttachment( - id = UUID.randomUUID().toString(), - mimeType = "audio/mp4", - base64 = Base64.encodeToString(bytes, Base64.NO_WRAP), - durationMillis = durationMillis - ) - } - } -} - -data class ImageAttachment( - val id: String, - val mimeType: String, - val base64: String, -) { - fun toBitmap(): Bitmap? { - return runCatching { - val bytes = Base64.decode(base64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - }.getOrNull() - } -} - -data class ChecklistItem( - val id: String, - val text: String, - val checked: Boolean = false, -) - -private fun checklistItemsFromJson(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return runCatching { - JSONArray(raw).toChecklistItems() - }.getOrDefault(emptyList()) -} - -private fun JSONArray.toChecklistItems(): List { - return buildList { - for (index in 0 until length()) { - val item = optJSONObject(index) ?: continue - val id = item.optString("id").ifBlank { UUID.randomUUID().toString() } - val text = item.optString("text").trim() - if (text.isBlank()) continue - add(ChecklistItem(id = id, text = text, checked = item.optBoolean("checked", false))) - } - } -} - -private fun List.toJsonArray(): JSONArray { - return JSONArray().also { array -> - forEach { item -> - array.put(JSONObject() - .put("id", item.id) - .put("text", item.text) - .put("checked", item.checked) - ) - } - } -} - -private fun List.toJsonString(): String = toJsonArray().toString() - -private fun imageAttachmentsFromJson(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return runCatching { - JSONArray(raw).toImageAttachments() - }.getOrDefault(emptyList()) -} - -private fun JSONArray.toImageAttachments(): List { - return buildList { - for (index in 0 until length()) { - val item = optJSONObject(index) ?: continue - val id = item.optString("id").ifBlank { UUID.randomUUID().toString() } - val mimeType = item.optString("mimeType", "image/jpeg").ifBlank { "image/jpeg" } - val base64 = item.optString("base64") - if (base64.isBlank()) continue - add(ImageAttachment(id = id, mimeType = mimeType, base64 = base64)) - } - } -} - -private fun List.toImageJsonArray(): JSONArray { - return JSONArray().also { array -> - forEach { image -> - array.put(JSONObject() - .put("id", image.id) - .put("mimeType", image.mimeType) - .put("base64", image.base64) - ) - } - } -} - -private fun List.toImageJsonString(): String = toImageJsonArray().toString() - -private fun audioAttachmentsFromJson(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return runCatching { - JSONArray(raw).toAudioAttachments() - }.getOrDefault(emptyList()) -} - -private fun JSONArray.toAudioAttachments(): List { - return buildList { - for (index in 0 until length()) { - val item = optJSONObject(index) ?: continue - val id = item.optString("id").ifBlank { UUID.randomUUID().toString() } - val mimeType = item.optString("mimeType", "audio/mp4").ifBlank { "audio/mp4" } - val base64 = item.optString("base64") - if (base64.isBlank()) continue - add(AudioAttachment( - id = id, - mimeType = mimeType, - base64 = base64, - durationMillis = item.optLong("durationMillis", 0L).coerceAtLeast(0L) - )) - } - } -} - -private fun List.toAudioJsonArray(): JSONArray { - return JSONArray().also { array -> - forEach { audio -> - array.put(JSONObject() - .put("id", audio.id) - .put("mimeType", audio.mimeType) - .put("base64", audio.base64) - .put("durationMillis", audio.durationMillis) - ) - } - } -} - -private fun List.toAudioJsonString(): String = toAudioJsonArray().toString() - -private fun labelsFromJson(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return runCatching { - JSONArray(raw).toLabels() - }.getOrDefault(emptyList()) -} - -private fun JSONArray.toLabels(): List { - return buildList { - for (index in 0 until length()) { - optString(index).takeIf { it.isNotBlank() }?.let { add(it) } - } - }.normalizeLabels() -} - -private fun String.toLabels(): List { - return split(',', ';') - .map { it.trim() } - .normalizeLabels() -} - -private fun Iterable.normalizeLabels(): List { - return asSequence() - .map { it.trim() } - .filter { it.isNotBlank() } - .distinctBy { it.lowercase(Locale.getDefault()) } - .take(50) - .toList() -} - -private fun labelsToJsonArray(labels: List): JSONArray { - return JSONArray().also { array -> - labels.normalizeLabels().forEach { label -> array.put(label) } - } -} - -private fun labelsToJsonString(labels: List): String = labelsToJsonArray(labels).toString() - -private fun collaboratorsFromJson(raw: String?): List = labelsFromJson(raw) - -private fun JSONArray.toCollaborators(): List = toLabels() - -private fun String.toCollaborators(): List { - return split(',', ';') - .map { it.trim() } - .normalizeLabels() -} - -private fun collaboratorsToJsonArray(collaborators: List): JSONArray = labelsToJsonArray(collaborators) - -private fun collaboratorsToJsonString(collaborators: List): String = collaboratorsToJsonArray(collaborators).toString() - -data class SyncResult( - val notes: List, - val synced: Boolean, - val authRequired: Boolean = false, -) - -data class AuthSession( - val username: String, - val displayName: String, - val authorizationHeader: String, - val refreshToken: String?, - val expiresAtMillis: Long, -) - -data class PendingOAuth( - val state: String, - val codeVerifier: String, -) - -data class AppUpdateInfo( - val version: String, - val androidVersionCode: Int, - val downloadUrl: String, - val sha256: String?, -) - -class AppUpdateClient(private val manifestUrl: String) { - fun latest(): AppUpdateInfo { - val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = 8000 - readTimeout = 8000 - doInput = true - setRequestProperty("Accept", "application/json") - } - return try { - val response = if (connection.responseCode in 200..299) { - connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - } else { - val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - throw IOException("HTTP ${connection.responseCode}: $errorText") - } - val release = JSONObject(response).getJSONObject("release") - AppUpdateInfo( - version = release.getString("version"), - androidVersionCode = release.getInt("androidVersionCode"), - downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(), - sha256 = release.optString("sha256").takeIf { it.isNotBlank() } - ) - } finally { - connection.disconnect() - } - } - - fun downloadPackage(context: Context, update: AppUpdateInfo): File { - VersionCleanup(context).cleanupOldPackageFiles(update.version) - val updateDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.cacheDir - if (!updateDir.exists()) { - updateDir.mkdirs() - } - val apk = File(updateDir, "keeper-android-${update.version}.apk") - val digest = MessageDigest.getInstance("SHA-256") - val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = 8000 - readTimeout = 30000 - doInput = true - setRequestProperty("Accept", APK_MIME_TYPE) - } - try { - if (connection.responseCode !in 200..299) { - val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - throw IOException("HTTP ${connection.responseCode}: $errorText") - } - connection.inputStream.use { input -> - apk.outputStream().use { output -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) - output.write(buffer, 0, read) - } - } - } - } finally { - connection.disconnect() - } - val actualSha256 = digest.digest().toHexString() - val expectedSha256 = update.sha256 - if (expectedSha256 != null && !expectedSha256.equals(actualSha256, ignoreCase = true)) { - apk.delete() - throw IOException("Downloaded APK checksum mismatch") - } - return apk - } - - private fun String.toAbsoluteArgusUrl(): String { - return if (startsWith("http://") || startsWith("https://")) { - this - } else { - "$ARGUS_BASE_URL$this" - } - } -} - -class VersionCleanup(private val context: Context) { - private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE) - - fun cleanupAfterUpdate(currentVersionCode: Int, currentVersionName: String) { - val previousVersionCode = preferences.getInt(KEY_LAST_VERSION_CODE, 0) - if (previousVersionCode != currentVersionCode) { - cleanupOldPackageFiles(currentVersionName) - preferences.edit() - .putInt(KEY_LAST_VERSION_CODE, currentVersionCode) - .apply() - } - } - - fun cleanupOldPackageFiles(currentVersionName: String) { - packageFileRoots().forEach { root -> - if (!root.exists()) return@forEach - root.walkTopDown() - .filter { file -> file.isFile && file.extension.equals("apk", ignoreCase = true) } - .filter { file -> file.name.isKeeperPackageName() && !file.name.contains(currentVersionName) } - .forEach { file -> runCatching { file.delete() } } - } - } - - private fun packageFileRoots(): List { - return listOfNotNull( - context.cacheDir, - context.codeCacheDir, - context.externalCacheDir, - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - context.getDir("updates", Context.MODE_PRIVATE) - ).distinctBy { it.absolutePath } - } - - private fun String.isKeeperPackageName(): Boolean { - val normalized = lowercase(Locale.US) - return normalized.contains("keeper") || - normalized.contains("qkeep") || - normalized.contains("qkeep-android") || - normalized.contains("kuzametki") || - normalized.contains("eu.qsfera.keeper") || - normalized.contains("keeper-android") - } - - private companion object { - const val KEY_LAST_VERSION_CODE = "last_version_code" - } -} - -private fun ByteArray.toHexString(): String = joinToString(separator = "") { byte -> - "%02x".format(byte) -} - -class KeepSettings(context: Context) { - private val preferences = context.getSharedPreferences("keeper_settings", Context.MODE_PRIVATE) - - fun addNewListItemsToBottom(): Boolean { - return preferences.getBoolean(KEY_ADD_NEW_LIST_ITEMS_TO_BOTTOM, true) - } - - fun setAddNewListItemsToBottom(enabled: Boolean) { - preferences.edit().putBoolean(KEY_ADD_NEW_LIST_ITEMS_TO_BOTTOM, enabled).apply() - } - - fun moveCheckedItemsToBottom(): Boolean { - return preferences.getBoolean(KEY_MOVE_CHECKED_ITEMS_TO_BOTTOM, true) - } - - fun setMoveCheckedItemsToBottom(enabled: Boolean) { - preferences.edit().putBoolean(KEY_MOVE_CHECKED_ITEMS_TO_BOTTOM, enabled).apply() - } - - private companion object { - const val KEY_ADD_NEW_LIST_ITEMS_TO_BOTTOM = "add_new_list_items_to_bottom" - const val KEY_MOVE_CHECKED_ITEMS_TO_BOTTOM = "move_checked_items_to_bottom" - } -} - -class AuthPreferences(context: Context) { - private val preferences = context.getSharedPreferences("keeper_auth", Context.MODE_PRIVATE) - - fun currentSession(): AuthSession? { - val username = preferences.getString(KEY_USERNAME, null) ?: return null - val displayName = preferences.getString(KEY_DISPLAY_NAME, username) ?: username - val authorizationHeader = preferences.getString(KEY_AUTHORIZATION_HEADER, null) ?: return null - val refreshToken = preferences.getString(KEY_REFRESH_TOKEN, null) - val expiresAtMillis = preferences.getLong(KEY_EXPIRES_AT_MILLIS, 0L) - return AuthSession( - username = username, - displayName = displayName, - authorizationHeader = authorizationHeader, - refreshToken = refreshToken, - expiresAtMillis = expiresAtMillis - ) - } - - fun save(session: AuthSession) { - preferences.edit() - .putString(KEY_USERNAME, session.username) - .putString(KEY_DISPLAY_NAME, session.displayName) - .putString(KEY_AUTHORIZATION_HEADER, session.authorizationHeader) - .putString(KEY_REFRESH_TOKEN, session.refreshToken) - .putLong(KEY_EXPIRES_AT_MILLIS, session.expiresAtMillis) - .apply() - } - - fun pendingOAuth(): PendingOAuth? { - val state = preferences.getString(KEY_OAUTH_STATE, null) ?: return null - val codeVerifier = preferences.getString(KEY_OAUTH_CODE_VERIFIER, null) ?: return null - return PendingOAuth(state, codeVerifier) - } - - fun savePendingOAuth(state: String, codeVerifier: String) { - preferences.edit() - .putString(KEY_OAUTH_STATE, state) - .putString(KEY_OAUTH_CODE_VERIFIER, codeVerifier) - .apply() - } - - fun clearPendingOAuth() { - preferences.edit() - .remove(KEY_OAUTH_STATE) - .remove(KEY_OAUTH_CODE_VERIFIER) - .apply() - } - - fun clear() { - preferences.edit().clear().apply() - } - - private companion object { - const val KEY_USERNAME = "username" - const val KEY_DISPLAY_NAME = "display_name" - const val KEY_AUTHORIZATION_HEADER = "authorization_header" - const val KEY_REFRESH_TOKEN = "refresh_token" - const val KEY_EXPIRES_AT_MILLIS = "expires_at_millis" - const val KEY_OAUTH_STATE = "oauth_state" - const val KEY_OAUTH_CODE_VERIFIER = "oauth_code_verifier" - } -} - -data class OAuthAuthorizationRequest( - val uri: Uri, - val state: String, - val codeVerifier: String, -) - -class QsferaAuthClient(private val baseUrl: String) { - fun createAuthorizationRequest(username: String?): OAuthAuthorizationRequest { - val state = randomUrlSafeString(byteCount = 15) - val codeVerifier = randomUrlSafeString(byteCount = 64) - val codeChallenge = codeChallenge(codeVerifier) - val normalizedBaseUrl = baseUrl.trimEnd('/') - val authorizationEndpoint = Uri.parse("$normalizedBaseUrl/signin/v1/identifier/_/authorize") - val uri = authorizationEndpoint.buildUpon() - .appendQueryParameter("redirect_uri", OAUTH_REDIRECT_URI) - .appendQueryParameter("client_id", OAUTH_CLIENT_ID) - .appendQueryParameter("response_type", "code") - .appendQueryParameter("scope", OAUTH_SCOPE) - .appendQueryParameter("prompt", "login") - .appendQueryParameter("code_challenge", codeChallenge) - .appendQueryParameter("code_challenge_method", "S256") - .appendQueryParameter("state", state) - .apply { - if (!username.isNullOrBlank()) { - appendQueryParameter("user", username) - appendQueryParameter("login_hint", username) - } - } - .build() - - return OAuthAuthorizationRequest(uri, state, codeVerifier) - } - - fun exchangeCode(code: String, codeVerifier: String): AuthSession { - val discovery = JSONObject(requestJson("/.well-known/openid-configuration")) - val tokenEndpoint = discovery.getString("token_endpoint") - val userInfoEndpoint = discovery.optString("userinfo_endpoint") - val tokenResponse = JSONObject( - postForm( - url = tokenEndpoint, - fields = mapOf( - "grant_type" to "authorization_code", - "code" to code, - "redirect_uri" to OAUTH_REDIRECT_URI, - "code_verifier" to codeVerifier, - "scope" to OAUTH_SCOPE, - "client_id" to OAUTH_CLIENT_ID - ) - ) - ) - val accessToken = tokenResponse.getString("access_token") - val authorizationHeader = "Bearer $accessToken" - val refreshToken = tokenResponse.optString("refresh_token").takeIf { it.isNotBlank() } - val expiresAtMillis = tokenResponse.expiresAtMillis() - val userInfo = if (userInfoEndpoint.isNotBlank()) { - runCatching { JSONObject(requestJson(userInfoEndpoint, authorizationHeader)) }.getOrNull() - } else { - null - } - val username = userInfo?.firstNonBlank("email", "sub") ?: "qsfera" - val displayName = userInfo?.firstNonBlank("name", "email", "sub") ?: username - - return AuthSession( - username = username, - displayName = displayName, - authorizationHeader = authorizationHeader, - refreshToken = refreshToken, - expiresAtMillis = expiresAtMillis - ) - } - - fun refreshSession(session: AuthSession): AuthSession { - val refreshToken = session.refreshToken ?: throw IOException("No refresh token") - val discovery = JSONObject(requestJson("/.well-known/openid-configuration")) - val tokenEndpoint = discovery.getString("token_endpoint") - val tokenResponse = JSONObject( - postForm( - url = tokenEndpoint, - fields = mapOf( - "grant_type" to "refresh_token", - "refresh_token" to refreshToken, - "scope" to OAUTH_SCOPE, - "client_id" to OAUTH_CLIENT_ID - ) - ) - ) - val accessToken = tokenResponse.getString("access_token") - return session.copy( - authorizationHeader = "Bearer $accessToken", - refreshToken = tokenResponse.optString("refresh_token").takeIf { it.isNotBlank() } ?: refreshToken, - expiresAtMillis = tokenResponse.expiresAtMillis() - ) - } - - private fun requestJson(urlOrPath: String, authorizationHeader: String? = null): String { - val url = if (urlOrPath.startsWith("http://") || urlOrPath.startsWith("https://")) { - urlOrPath - } else { - "${baseUrl.trimEnd('/')}$urlOrPath" - } - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = 8000 - readTimeout = 8000 - doInput = true - setRequestProperty("Accept", "application/json") - if (authorizationHeader != null) { - setRequestProperty("Authorization", authorizationHeader) - } - } - - return readResponse(connection) - } - - private fun postForm(url: String, fields: Map): String { - val body = fields.entries.joinToString("&") { (key, value) -> - "${key.formEncoded()}=${value.formEncoded()}" - }.toByteArray(Charsets.UTF_8) - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - connectTimeout = 8000 - readTimeout = 8000 - doInput = true - doOutput = true - setRequestProperty("Accept", "application/json") - setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") - } - return try { - connection.outputStream.use { output -> - output.write(body) - } - readResponse(connection) - } finally { - connection.disconnect() - } - } - - private fun readResponse(connection: HttpURLConnection): String { - return try { - if (connection.responseCode in 200..299) { - connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - } else { - val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - throw IOException("HTTP ${connection.responseCode}: $errorText") - } - } finally { - connection.disconnect() - } - } - - private fun randomUrlSafeString(byteCount: Int): String { - val bytes = ByteArray(byteCount) - SecureRandom().nextBytes(bytes) - val flags = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE - return Base64.encodeToString(bytes, flags) - } - - private fun codeChallenge(codeVerifier: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(Charsets.UTF_8)) - val flags = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE - return Base64.encodeToString(digest, flags) - } - - private fun JSONObject.firstNonBlank(vararg keys: String): String? { - return keys.asSequence() - .map { key -> optString(key) } - .firstOrNull { it.isNotBlank() } - } - - private fun JSONObject.expiresAtMillis(): Long { - val expiresInSeconds = optLong("expires_in", 3600L).coerceAtLeast(60L) - return System.currentTimeMillis() + expiresInSeconds * 1000L - } - - private fun String.formEncoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) -} - -class NotesRepository( - context: Context, - private val authPreferences: AuthPreferences, - authClient: QsferaAuthClient -) { - private val database = NotesDatabase(context) - private val apiClient = NotesApiClient( - baseUrl = BuildConfig.KEEPER_API_BASE_URL, - cloudBaseUrl = BuildConfig.QSFERA_CLOUD_BASE_URL, - authPreferences = authPreferences, - authClient = authClient - ) - - suspend fun readLocalNotes(): List = withContext(Dispatchers.IO) { - database.listNotes() - } - - suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { - val localNotes = database.listNotes() - try { - localNotes.forEach { apiClient.upsert(it) } - val remoteNotes = apiClient.fetchNotes() - val merged = (localNotes + remoteNotes) - .groupBy { it.id } - .map { (_, versions) -> versions.maxBy { it.updatedAt } } - database.replaceAll(merged) - SyncResult(database.listNotes(), synced = true) - } catch (_: Exception) { - SyncResult( - notes = database.listNotes(), - synced = false, - authRequired = authPreferences.currentSession() == null - ) - } - } - - suspend fun save(note: Note): Boolean = withContext(Dispatchers.IO) { - database.upsert(note) - runCatching { - apiClient.upsert(note) - }.isSuccess - } - - suspend fun saveAll(notes: List): Boolean = withContext(Dispatchers.IO) { - notes.forEach { note -> database.upsert(note) } - runCatching { - notes.forEach { note -> apiClient.upsert(note) } - }.isSuccess - } - - suspend fun delete(id: String): Boolean = withContext(Dispatchers.IO) { - database.delete(id) - runCatching { - apiClient.delete(id) - }.isSuccess - } -} - -class NotesDatabase(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { - override fun onCreate(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE notes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - body TEXT NOT NULL, - body_formats TEXT NOT NULL DEFAULT '[]', - color INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - pinned INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - deleted INTEGER NOT NULL DEFAULT 0, - checklist INTEGER NOT NULL DEFAULT 0, - checklist_items TEXT NOT NULL DEFAULT '[]', - labels TEXT NOT NULL DEFAULT '[]', - owner TEXT NOT NULL DEFAULT '', - collaborators TEXT NOT NULL DEFAULT '[]', - reminder_at INTEGER, - images TEXT NOT NULL DEFAULT '[]', - sort_order INTEGER NOT NULL DEFAULT 0, - audios TEXT NOT NULL DEFAULT '[]' - ) - """.trimIndent() - ) - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 2) { - db.execSQL("ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE notes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE notes ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0") - } - if (oldVersion < 3) { - db.execSQL("ALTER TABLE notes ADD COLUMN checklist INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE notes ADD COLUMN checklist_items TEXT NOT NULL DEFAULT '[]'") - } - if (oldVersion < 4) { - db.execSQL("ALTER TABLE notes ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'") - } - if (oldVersion < 5) { - db.execSQL("ALTER TABLE notes ADD COLUMN reminder_at INTEGER") - } - if (oldVersion < 6) { - db.execSQL("ALTER TABLE notes ADD COLUMN images TEXT NOT NULL DEFAULT '[]'") - } - if (oldVersion < 7) { - db.execSQL("ALTER TABLE notes ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0") - db.execSQL("UPDATE notes SET sort_order = updated_at WHERE sort_order = 0") - } - if (oldVersion < 8) { - db.execSQL("ALTER TABLE notes ADD COLUMN audios TEXT NOT NULL DEFAULT '[]'") - } - if (oldVersion < 9) { - db.execSQL("ALTER TABLE notes ADD COLUMN body_formats TEXT NOT NULL DEFAULT '[]'") - } - if (oldVersion < 10) { - db.execSQL("ALTER TABLE notes ADD COLUMN owner TEXT NOT NULL DEFAULT ''") - db.execSQL("ALTER TABLE notes ADD COLUMN collaborators TEXT NOT NULL DEFAULT '[]'") - } - } - - fun listNotes(): List { - val notes = mutableListOf() - readableDatabase.query( - "notes", - arrayOf("id", "title", "body", "body_formats", "color", "updated_at", "sort_order", "pinned", "archived", "deleted", "checklist", "checklist_items", "labels", "owner", "collaborators", "reminder_at", "images", "audios"), - null, - null, - null, - null, - "sort_order DESC, updated_at DESC" - ).use { cursor -> - while (cursor.moveToNext()) { - notes += cursor.toNote() - } - } - return notes - } - - fun upsert(note: Note) { - writableDatabase.insertWithOnConflict( - "notes", - null, - note.toContentValues(), - SQLiteDatabase.CONFLICT_REPLACE - ) - } - - fun replaceAll(notes: List) { - writableDatabase.beginTransaction() - try { - writableDatabase.delete("notes", null, null) - notes.forEach { note -> - writableDatabase.insertWithOnConflict( - "notes", - null, - note.toContentValues(), - SQLiteDatabase.CONFLICT_REPLACE - ) - } - writableDatabase.setTransactionSuccessful() - } finally { - writableDatabase.endTransaction() - } - } - - fun delete(id: String) { - writableDatabase.delete("notes", "id = ?", arrayOf(id)) - } - - private fun Cursor.toNote(): Note = Note( - id = getString(getColumnIndexOrThrow("id")), - title = getString(getColumnIndexOrThrow("title")), - body = getString(getColumnIndexOrThrow("body")), - bodyFormats = bodyTextFormatsFromJson(getString(getColumnIndexOrThrow("body_formats"))), - color = getInt(getColumnIndexOrThrow("color")), - updatedAt = getLong(getColumnIndexOrThrow("updated_at")), - sortOrder = getLong(getColumnIndexOrThrow("sort_order")).takeIf { it > 0L } - ?: getLong(getColumnIndexOrThrow("updated_at")), - pinned = getInt(getColumnIndexOrThrow("pinned")) != 0, - archived = getInt(getColumnIndexOrThrow("archived")) != 0, - deleted = getInt(getColumnIndexOrThrow("deleted")) != 0, - checklist = getInt(getColumnIndexOrThrow("checklist")) != 0, - checklistItems = checklistItemsFromJson(getString(getColumnIndexOrThrow("checklist_items"))), - labels = labelsFromJson(getString(getColumnIndexOrThrow("labels"))), - owner = getString(getColumnIndexOrThrow("owner")), - collaborators = collaboratorsFromJson(getString(getColumnIndexOrThrow("collaborators"))), - reminderAt = nullableLong("reminder_at"), - images = imageAttachmentsFromJson(getString(getColumnIndexOrThrow("images"))), - audios = audioAttachmentsFromJson(getString(getColumnIndexOrThrow("audios"))) - ) - - private fun Note.toContentValues(): ContentValues = ContentValues().apply { - put("id", id) - put("title", title) - put("body", body) - put("body_formats", bodyFormats.toBodyFormatJsonString()) - put("color", color) - put("updated_at", updatedAt) - put("sort_order", sortOrder) - put("pinned", if (pinned) 1 else 0) - put("archived", if (archived) 1 else 0) - put("deleted", if (deleted) 1 else 0) - put("checklist", if (checklist) 1 else 0) - put("checklist_items", checklistItems.toJsonString()) - put("labels", labelsToJsonString(labels)) - put("owner", owner) - put("collaborators", collaboratorsToJsonString(collaborators)) - if (reminderAt == null) { - putNull("reminder_at") - } else { - put("reminder_at", reminderAt) - } - put("images", images.toImageJsonString()) - put("audios", audios.toAudioJsonString()) - } - - private fun Cursor.nullableLong(columnName: String): Long? { - val index = getColumnIndexOrThrow(columnName) - return if (isNull(index)) null else getLong(index) - } -} - -class NotesApiClient( - private val baseUrl: String, - private val cloudBaseUrl: String, - private val authPreferences: AuthPreferences, - private val authClient: QsferaAuthClient -) { - private val sendAuthHeader = sameHost(baseUrl, cloudBaseUrl) - - fun fetchNotes(): List { - val response = request(path = "/api/notes", method = "GET") - val json = JSONArray(response) - return buildList { - for (index in 0 until json.length()) { - add(json.getJSONObject(index).toNote()) - } - } - } - - fun upsert(note: Note) { - request( - path = "/api/notes/${note.id.urlEncoded()}", - method = "PUT", - body = note.toJson().toString() - ) - } - - fun delete(id: String) { - request(path = "/api/notes/${id.urlEncoded()}", method = "DELETE") - } - - private fun request(path: String, method: String, body: String? = null, retryOnUnauthorized: Boolean = true): String { - val normalizedBaseUrl = baseUrl.trimEnd('/') - val connection = (URL("$normalizedBaseUrl$path").openConnection() as HttpURLConnection).apply { - requestMethod = method - connectTimeout = 5000 - readTimeout = 5000 - doInput = true - setRequestProperty("Accept", "application/json") - if (sendAuthHeader) { - currentAuthorizationHeader()?.let { authorizationHeader -> - setRequestProperty("Authorization", authorizationHeader) - } - } - if (body != null) { - doOutput = true - setRequestProperty("Content-Type", "application/json; charset=utf-8") - } - } - - return try { - if (body != null) { - connection.outputStream.use { output -> - output.write(body.toByteArray(Charsets.UTF_8)) - } - } - - val responseText = if (connection.responseCode in 200..299) { - connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - } else { - val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - if (connection.responseCode == HttpURLConnection.HTTP_UNAUTHORIZED && retryOnUnauthorized && refreshSession()) { - return request(path, method, body, retryOnUnauthorized = false) - } - if (connection.responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) { - authPreferences.clear() - } - throw IOException("HTTP ${connection.responseCode}: $errorText") - } - responseText - } finally { - connection.disconnect() - } - } - - private fun JSONObject.toNote(): Note = Note( - id = getString("id"), - title = optString("title"), - body = optString("body"), - bodyFormats = optJSONArray("bodyFormats")?.toBodyTextFormats().orEmpty(), - color = Color.parseColor(optString("color", "#FFF475")), - updatedAt = optLong("updatedAt"), - sortOrder = optLong("sortOrder", optLong("updatedAt")), - pinned = optBoolean("pinned", false), - archived = optBoolean("archived", false), - deleted = optBoolean("deleted", false), - checklist = optBoolean("checklist", false), - checklistItems = optJSONArray("checklistItems")?.toChecklistItems().orEmpty(), - labels = optJSONArray("labels")?.toLabels().orEmpty(), - owner = optString("owner"), - collaborators = optJSONArray("collaborators")?.toCollaborators().orEmpty(), - reminderAt = optNullableLong("reminderAt"), - images = optJSONArray("images")?.toImageAttachments().orEmpty(), - audios = optJSONArray("audios")?.toAudioAttachments().orEmpty() - ) - - private fun Note.toJson(): JSONObject = JSONObject() - .put("id", id) - .put("title", title) - .put("body", body) - .put("bodyFormats", bodyFormats.toBodyFormatJsonArray()) - .put("color", String.format(Locale.US, "#%06X", color and 0x00FFFFFF)) - .put("updatedAt", updatedAt) - .put("sortOrder", sortOrder) - .put("pinned", pinned) - .put("archived", archived) - .put("deleted", deleted) - .put("checklist", checklist) - .put("checklistItems", checklistItems.toJsonArray()) - .put("labels", labelsToJsonArray(labels)) - .put("owner", owner) - .put("collaborators", collaboratorsToJsonArray(collaborators)) - .put("reminderAt", reminderAt ?: JSONObject.NULL) - .put("images", images.toImageJsonArray()) - .put("audios", audios.toAudioJsonArray()) - - private fun JSONObject.optNullableLong(key: String): Long? { - if (!has(key) || isNull(key)) return null - val value = optLong(key, 0L) - return value.takeIf { it > 0L } - } - - private fun String.urlEncoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) - - private fun currentAuthorizationHeader(): String? { - val session = authPreferences.currentSession() ?: return null - if (!session.shouldRefresh()) { - return session.authorizationHeader - } - return if (refreshSession()) { - authPreferences.currentSession()?.authorizationHeader - } else { - session.authorizationHeader - } - } - - private fun refreshSession(): Boolean { - val session = authPreferences.currentSession() ?: return false - val refreshed = runCatching { authClient.refreshSession(session) }.getOrNull() - return if (refreshed != null) { - authPreferences.save(refreshed) - true - } else { - authPreferences.clear() - false - } - } - - private fun AuthSession.shouldRefresh(): Boolean { - return refreshToken != null && expiresAtMillis > 0L && expiresAtMillis <= System.currentTimeMillis() + 60_000L - } - - private fun sameHost(left: String, right: String): Boolean { - return runCatching { - URL(left).host.equals(URL(right).host, ignoreCase = true) - }.getOrDefault(false) - } -} diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_account_circle_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_account_circle_24.xml deleted file mode 100644 index 6634b2cc..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_account_circle_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_add_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_add_24.xml deleted file mode 100644 index 9afd5697..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_add_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_archive_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_archive_24.xml deleted file mode 100644 index 58c4c6a8..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_archive_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_checklist_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_checklist_24.xml deleted file mode 100644 index 41a04775..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_checklist_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_delete_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_delete_24.xml deleted file mode 100644 index 66a4b1d5..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_delete_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_draw_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_draw_24.xml deleted file mode 100644 index 79fdfa13..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_draw_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_image_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_image_24.xml deleted file mode 100644 index 4cda3b24..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_image_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_keeper_icon.xml b/Keeper/keeperApp/src/main/res/drawable/ic_keeper_icon.xml deleted file mode 100644 index 207e69e5..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_keeper_icon.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_menu_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_menu_24.xml deleted file mode 100644 index 2a947824..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_menu_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_mic_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_mic_24.xml deleted file mode 100644 index 54ad1a2a..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_mic_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_notification_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_notification_24.xml deleted file mode 100644 index c0d124fc..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_notification_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_play_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_play_24.xml deleted file mode 100644 index 1f909026..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_play_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_push_pin_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_push_pin_24.xml deleted file mode 100644 index adb9bed4..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_push_pin_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_restore_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_restore_24.xml deleted file mode 100644 index e9b3c672..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_restore_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_search_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_search_24.xml deleted file mode 100644 index 0db32a9c..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_search_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_share_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_share_24.xml deleted file mode 100644 index 5114ab0f..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_share_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/drawable/ic_sync_24.xml b/Keeper/keeperApp/src/main/res/drawable/ic_sync_24.xml deleted file mode 100644 index 16de42b5..00000000 --- a/Keeper/keeperApp/src/main/res/drawable/ic_sync_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/values/colors.xml b/Keeper/keeperApp/src/main/res/values/colors.xml deleted file mode 100644 index ac017efc..00000000 --- a/Keeper/keeperApp/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #FFFFFF - #FFFFFF - #F1F3F4 - #202124 - #5F6368 - #DADCE0 - #F9AB00 - diff --git a/Keeper/keeperApp/src/main/res/values/strings.xml b/Keeper/keeperApp/src/main/res/values/strings.xml deleted file mode 100644 index a6aad18e..00000000 --- a/Keeper/keeperApp/src/main/res/values/strings.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - Все - Текст - Списки - Картинки - Рисунки - Аудио - Напоминания - Цвет %1$d - Цвет - Ничего не найдено - Жирный - Курсив - Подчеркивание - Заголовок H1 - Заголовок H2 - Убрать формат - Выделите текст - Отправить - Не удалось отправить заметку - Напомнить: %1$s - Изображений: %1$d - Аудио: %1$d - Соавторы QSfera через запятую - Совм: %1$s - Поведение списков - Новые пункты внизу - Отмеченные пункты вниз - КуЗаметки - Поиск заметок - Поиск напоминаний - Поиск в архиве - Поиск в корзине - Поиск: %1$s - Заметок нет - Напоминаний нет - Архив пуст - Корзина пуста - В этой метке нет заметок - Заметки - Напоминания - Архив - Корзина - Метка: %1$s - # %1$s - Закрепленные - Другие - %1$s - %2$s - Заголовок - Заметка - Сохранить - Отмена - Удалить - Удалить навсегда - Новый список - Новая заметка с изображением - Новый рисунок - Голосовая заметка - Чекбоксы - Текстовая заметка - Добавить пункт - Добавить изображение - Добавить рисунок - Удалить изображение - Не удалось добавить изображение - Еще изображений: %1$d - Сохранить рисунок - Очистить - Сначала нарисуйте что-нибудь - Добавить аудио - Удалить аудио - Воспроизвести - Аудио %1$d с - Нажмите запись - Идет запись... - Записано: %1$d с - Начать запись - Остановить - Сначала запишите аудио - Не удалось записать аудио - Не удалось воспроизвести аудио - Разрешите доступ к микрофону - Голосовая заметка - Пункт списка - Пустой пункт - Удалить пункт - Еще пунктов: %1$d - Метки через запятую - Добавить напоминание - Убрать - Напоминание: %1$s - Напомнить: %1$s - Напоминание QKeep - Напоминания QKeep - Уведомления о заметках с заданным временем - Закрепить - Открепить - В архив - Вернуть - В корзину - Новая заметка - Редактирование - Синхронизировано - Локально - Синхронизация - Облако недоступно - Вход в КуЗаметки - qsfera.kusoft.xyz - Логин QSfera - Войти через QSfera - Открываю QSfera - Вход в QSfera не завершен - Пароль вводится на защищенной странице QSfera. - Аккаунт - Выйти - Вход выполнен: %1$s - Требуется вход в QSfera - Меню - Настройки - Закрыть - Установлена версия %1$s (%2$d) - Проверка обновлений выполняется через Argus. - Старые APK QKeep из папок приложения удаляются автоматически после обновления. - Проверить обновления - Установить обновление - Проверяю обновления… - Скачиваю обновление… - Открываю установщик Android… - Установлена актуальная версия %1$s (%2$d). - Доступна версия %1$s (%2$d). - Не удалось проверить обновления. - Не удалось скачать обновление. - Разрешите установку из этого источника и повторите установку обновления. - На устройстве не найден установщик APK. - diff --git a/Keeper/keeperApp/src/main/res/values/styles.xml b/Keeper/keeperApp/src/main/res/values/styles.xml deleted file mode 100644 index ae3580ef..00000000 --- a/Keeper/keeperApp/src/main/res/values/styles.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/Keeper/keeperApp/src/main/res/xml/file_paths.xml b/Keeper/keeperApp/src/main/res/xml/file_paths.xml deleted file mode 100644 index 7b86222d..00000000 --- a/Keeper/keeperApp/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/Keeper/keeperApp/src/main/res/xml/network_security_config.xml b/Keeper/keeperApp/src/main/res/xml/network_security_config.xml deleted file mode 100644 index f5481f98..00000000 --- a/Keeper/keeperApp/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - 10.0.2.2 - localhost - 127.0.0.1 - - diff --git a/Keeper/qkeep-runtime-0.1.21-pulled.png b/Keeper/qkeep-runtime-0.1.21-pulled.png deleted file mode 100644 index 79791fc049dc694925ce8931e468ca7402986125..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15580 zcmeI2ze_?<6vvPFsHdO>PKkqC|3XWkV(42e#I^_rDF}kVh$ta*=*QC3C{7MdB26u! zwX{UER6-OEH3Y9Ur?7LIA3=-L=MCq*dwFj;++B$A(sgSqd^e~IoWhx#F`Na z6zwscS&tq*=*=ppoPw=a#V#G`*jLVHQqsDMeTc-xNhb^2b+?xcNqn-~_759LYvR1Q z*d9FI)_wZA{oL{EO_rx(8WW8xGx^4V9Iy`93)BN@BD#Paf&^d@Xb2u-5pYI0Bb*V= z2xo*d!WrR=a7H*IoY8-r(bAS%wTzLtA3lBBn+N*1TQfa~Uc?mCZlkYJzN~SL?P=|I zNOks?H{3v22gUgfjeJ7JSTO7t%0=H)L#m}F0kO~|cxdJR^=U~YwqyPUKsV7%bQ9e~ zH~U&F(M?Pi*nq|Mq9#ZQQi7BqB}fTU0u)Db8ozcE&KfGWzV}{E5~~*+{(GM1Kqin0 yWCEE$CXfkaG7>VOzouA5e@&C%(O-kd{^qeu8JtBr@2@Kr&7U}PxpXU4to;C|Oit+l diff --git a/Keeper/qkeep-runtime-0.1.21.png b/Keeper/qkeep-runtime-0.1.21.png deleted file mode 100644 index ad0ee229d5ba515e00f1242cb56f3a0c77a749f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31162 zcmeI5&q^FY5XL{*Vf_P|fTzeFy!i@Vgxy6+yck)`ErdOU1O!1)M06o9;p=ufRb5@? zvJbF_-z?5fPxs77?N{~HpPrfex72H0>9h8=r=zx3TfBX#FFMzvwFjNH?_Djmqj3z< zogGtZsnHztpt&7&Vsuc;8EwVM?Mih#>f6!}ebS`2rfqDFE0rE(;0Oc<$N(I;kpu_G033w7 zfG(g5=mNSxyih7KKn5PEg9Bs$4yNRhgbaiX%sB)H$iQO+aDWWJflB}!AcF=2Y$CeQ zfB-jQ6S0ZdMCPF|(}Jp^m}Ow55F8)_XGCy-48Va)030BL1_Nv&y3l|CH?Fsd-}PiV zGK8}9ETdQDcl2ocWB03VjT2dfCJ>9Qiz`cgv!@04 zGSSQX69I@eVNe(}>;~DHoJ&EpNwnFh!d66^M4N#Ha@l166A5r7T$xyaD?<`+B~?u7 zzM%`$u&H4uKaiOvGwWC&tAM8zHUUowPYF*cbOBEZPbrYVY(HlEB@(D%ud88Gt)^PN zDb?y*i)Rk(9S*q+*8Zcb?Wum-7QfS`{7%#_sb40mqLpZ+V}aSS= limit { - break - } - } - return normalized -} - -func sortedNotes(noteMap map[string]Note) []Note { - notes := make([]Note, 0, len(noteMap)) - for _, note := range noteMap { - notes = append(notes, note) - } - sort.Slice(notes, func(i, j int) bool { - leftOrder := notes[i].SortOrder - if leftOrder <= 0 { - leftOrder = notes[i].UpdatedAt - } - rightOrder := notes[j].SortOrder - if rightOrder <= 0 { - rightOrder = notes[j].UpdatedAt - } - if leftOrder == rightOrder { - if notes[i].UpdatedAt != notes[j].UpdatedAt { - return notes[i].UpdatedAt > notes[j].UpdatedAt - } - return notes[i].ID < notes[j].ID - } - return leftOrder > rightOrder - }) - return notes -} diff --git a/Server/services/keepernotes/pkg/notes/store_test.go b/Server/services/keepernotes/pkg/notes/store_test.go deleted file mode 100644 index 2c017c57..00000000 --- a/Server/services/keepernotes/pkg/notes/store_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package notes - -import ( - "path/filepath" - "testing" -) - -func TestFileStoreUpsertListDeleteAndPersist(t *testing.T) { - path := filepath.Join(t.TempDir(), "notes.json") - reminderAt := int64(2000) - - store, err := OpenFileStore(path) - if err != nil { - t.Fatalf("OpenFileStore() error = %v", err) - } - - if _, err := store.Upsert(Note{ID: "old", Title: "Old", Color: "#FFFFFF", UpdatedAt: 100}); err != nil { - t.Fatalf("Upsert(old) error = %v", err) - } - if _, err := store.Upsert(Note{ - ID: "new", - Title: "New", - Body: "Formatted body", - BodyFormats: []BodyFormat{{Start: 0, End: 9, Style: "bold"}}, - Owner: "owner-1", - Collaborators: []string{"friend@example.org", "friend@example.org", " "}, - Color: "#FFF475", - UpdatedAt: 200, - SortOrder: 300, - Pinned: true, - Archived: true, - Deleted: true, - Checklist: true, - Items: []Item{{ID: "item-1", Text: "Buy milk", Checked: true}}, - Labels: []string{"Home", "Errands"}, - ReminderAt: &reminderAt, - Images: []Image{{ID: "image-1", MimeType: "image/jpeg", Base64: "abc123"}}, - Audios: []Audio{{ID: "audio-1", MimeType: "audio/mp4", Base64: "def456", DurationMillis: 1200}}, - }); err != nil { - t.Fatalf("Upsert(new) error = %v", err) - } - - notes := store.List() - if len(notes) != 2 { - t.Fatalf("List() length = %d, want 2", len(notes)) - } - if notes[0].ID != "new" { - t.Fatalf("List()[0].ID = %q, want %q", notes[0].ID, "new") - } - if notes[0].SortOrder != 300 { - t.Fatalf("List()[0].SortOrder = %d, want 300", notes[0].SortOrder) - } - if notes[0].Body != "Formatted body" || len(notes[0].BodyFormats) != 1 || notes[0].BodyFormats[0].Style != "bold" { - t.Fatalf("List()[0] body formatting = body:%q formats:%+v, want one bold range", notes[0].Body, notes[0].BodyFormats) - } - if notes[0].Owner != "owner-1" || len(notes[0].Collaborators) != 1 || notes[0].Collaborators[0] != "friend@example.org" { - t.Fatalf("List()[0] sharing = owner:%q collaborators:%+v, want one collaborator", notes[0].Owner, notes[0].Collaborators) - } - if !notes[0].Pinned || !notes[0].Archived || !notes[0].Deleted { - t.Fatalf("List()[0] state = pinned:%v archived:%v deleted:%v, want all true", notes[0].Pinned, notes[0].Archived, notes[0].Deleted) - } - if !notes[0].Checklist || len(notes[0].Items) != 1 || notes[0].Items[0].Text != "Buy milk" || !notes[0].Items[0].Checked { - t.Fatalf("List()[0] checklist = %+v, want one checked item", notes[0].Items) - } - if len(notes[0].Labels) != 2 || notes[0].Labels[0] != "Home" || notes[0].Labels[1] != "Errands" { - t.Fatalf("List()[0] labels = %+v, want Home and Errands", notes[0].Labels) - } - if notes[0].ReminderAt == nil || *notes[0].ReminderAt != reminderAt { - t.Fatalf("List()[0] ReminderAt = %v, want %d", notes[0].ReminderAt, reminderAt) - } - if len(notes[0].Images) != 1 || notes[0].Images[0].ID != "image-1" || notes[0].Images[0].Base64 != "abc123" { - t.Fatalf("List()[0] images = %+v, want image-1", notes[0].Images) - } - if len(notes[0].Audios) != 1 || notes[0].Audios[0].ID != "audio-1" || notes[0].Audios[0].Base64 != "def456" { - t.Fatalf("List()[0] audios = %+v, want audio-1", notes[0].Audios) - } - - if err := store.Delete("new"); err != nil { - t.Fatalf("Delete(new) error = %v", err) - } - - reopened, err := OpenFileStore(path) - if err != nil { - t.Fatalf("OpenFileStore(reopen) error = %v", err) - } - - notes = reopened.List() - if len(notes) != 1 { - t.Fatalf("reopened List() length = %d, want 1", len(notes)) - } - if notes[0].ID != "old" { - t.Fatalf("reopened List()[0].ID = %q, want %q", notes[0].ID, "old") - } -}