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 7454180f..00000000 Binary files a/Keeper/gradle/wrapper/gradle-wrapper.jar and /dev/null differ 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 79791fc0..00000000 Binary files a/Keeper/qkeep-runtime-0.1.21-pulled.png and /dev/null differ diff --git a/Keeper/qkeep-runtime-0.1.21.png b/Keeper/qkeep-runtime-0.1.21.png deleted file mode 100644 index ad0ee229..00000000 Binary files a/Keeper/qkeep-runtime-0.1.21.png and /dev/null differ diff --git a/Keeper/scripts/publish-argus.ps1 b/Keeper/scripts/publish-argus.ps1 deleted file mode 100644 index 8d6f03f7..00000000 --- a/Keeper/scripts/publish-argus.ps1 +++ /dev/null @@ -1,99 +0,0 @@ -param( - [string]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }), - [string]$Username = $env:ARGUS_USERNAME, - [string]$Password = $env:ARGUS_PASSWORD, - [string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk", - [string]$Slug = "keeper-android", - [string]$Name = "QKeep", - [string]$Summary = "Cloud notes app for QSfera with local storage and sync.", - [string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs Argus updates from inside the app.", - [string]$AndroidPackageName = "eu.qsfera.keeper", - [string]$Version = "0.1.21", - [int]$AndroidVersionCode = 22, - [string]$Channel = "stable", - [string]$Platform = "android", - [string]$PackageKind = "apk", - [string]$Notes = "Adds Keep-like list behavior settings for new and checked checklist items." -) - -$ErrorActionPreference = "Stop" - -if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) { - throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing." -} - -$resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path -$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/")) -$slugEscaped = [Uri]::EscapeDataString($Slug) - -Add-Type -AssemblyName System.Net.Http - -$handler = [System.Net.Http.HttpClientHandler]::new() -$handler.CookieContainer = [System.Net.CookieContainer]::new() -$client = [System.Net.Http.HttpClient]::new($handler) -$client.BaseAddress = $baseUri - -function New-JsonContent([object]$Value) { - $json = $Value | ConvertTo-Json -Depth 8 - return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json") -} - -function Read-ResponseBody($Response) { - return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() -} - -function Assert-Success($Response, [string]$Action) { - if (-not $Response.IsSuccessStatusCode) { - $body = Read-ResponseBody $Response - throw "$Action failed: HTTP $([int]$Response.StatusCode) $body" - } -} - -try { - $loginPayload = @{ - username = $Username - password = $Password - } - $loginResponse = $client.PostAsync("/api/admin/session/login", (New-JsonContent $loginPayload)).GetAwaiter().GetResult() - Assert-Success $loginResponse "Argus login" - - $metadataPayload = @{ - name = $Name - summary = $Summary - description = $Description - androidPackageName = $AndroidPackageName - repositoryUrl = $null - homepageUrl = $null - isListed = $true - } - $metadataResponse = $client.PutAsync("/api/admin/apps/$slugEscaped", (New-JsonContent $metadataPayload)).GetAwaiter().GetResult() - Assert-Success $metadataResponse "Argus app metadata update" - - $form = [System.Net.Http.MultipartFormDataContent]::new() - $form.Add([System.Net.Http.StringContent]::new($Version), "Version") - $form.Add([System.Net.Http.StringContent]::new($Channel), "Channel") - $form.Add([System.Net.Http.StringContent]::new($Platform), "Platform") - $form.Add([System.Net.Http.StringContent]::new($PackageKind), "PackageKind") - $form.Add([System.Net.Http.StringContent]::new($AndroidVersionCode.ToString()), "AndroidVersionCode") - $form.Add([System.Net.Http.StringContent]::new($Notes), "Notes") - - $fileStream = [IO.File]::OpenRead($resolvedApk) - $fileContent = [System.Net.Http.StreamContent]::new($fileStream) - $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("application/vnd.android.package-archive") - $form.Add($fileContent, "Package", [IO.Path]::GetFileName($resolvedApk)) - - $releaseResponse = $client.PostAsync("/api/admin/apps/$slugEscaped/releases", $form).GetAwaiter().GetResult() - Assert-Success $releaseResponse "Argus release upload" - - $releaseBody = Read-ResponseBody $releaseResponse - Write-Output $releaseBody -} finally { - if ($fileStream) { - $fileStream.Dispose() - } - if ($form) { - $form.Dispose() - } - $client.Dispose() - $handler.Dispose() -} diff --git a/Keeper/settings.gradle b/Keeper/settings.gradle deleted file mode 100644 index 1624e512..00000000 --- a/Keeper/settings.gradle +++ /dev/null @@ -1,21 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - maven { - url "https://jitpack.io" - } - } -} - -rootProject.name = "QSferaKeeper" -include ":keeperApp" diff --git a/Server/services/keepernotes/README.md b/Server/services/keepernotes/README.md deleted file mode 100644 index 1dade18e..00000000 --- a/Server/services/keepernotes/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Keeper Notes Service - -Small REST service for the `G:\Repos\QSfera\Keeper` Android app. - -## Run - -```powershell -go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.json -``` - -The Android emulator can reach the host service through `http://10.0.2.2:8098`. - -## API - -* `GET /healthz` -* `GET /api/notes` -* `PUT /api/notes/{id}` -* `DELETE /api/notes/{id}` - -Notes are stored in a JSON file configured by `-data` or `KEEPER_NOTES_DATA`. diff --git a/Server/services/keepernotes/cmd/keepernotes/main.go b/Server/services/keepernotes/cmd/keepernotes/main.go deleted file mode 100644 index 6738510a..00000000 --- a/Server/services/keepernotes/cmd/keepernotes/main.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "flag" - "log" - "net/http" - "os" - - "github.com/qsfera/server/services/keepernotes/pkg/notes" -) - -func main() { - addr := getenv("KEEPER_NOTES_ADDR", ":8098") - dataPath := getenv("KEEPER_NOTES_DATA", "keeper-notes.json") - - flag.StringVar(&addr, "addr", addr, "HTTP listen address") - flag.StringVar(&dataPath, "data", dataPath, "JSON file used to store notes") - flag.Parse() - - store, err := notes.OpenFileStore(dataPath) - if err != nil { - log.Fatalf("open note store: %v", err) - } - - log.Printf("keeper notes service listening on %s, data file %s", addr, dataPath) - if err := http.ListenAndServe(addr, notes.NewServer(store)); err != nil { - log.Fatal(err) - } -} - -func getenv(key, fallback string) string { - value := os.Getenv(key) - if value == "" { - return fallback - } - return value -} diff --git a/Server/services/keepernotes/pkg/notes/server.go b/Server/services/keepernotes/pkg/notes/server.go deleted file mode 100644 index 9f81faa4..00000000 --- a/Server/services/keepernotes/pkg/notes/server.go +++ /dev/null @@ -1,105 +0,0 @@ -package notes - -import ( - "encoding/json" - "net/http" - "net/url" - "strings" -) - -type Server struct { - store Store -} - -func NewServer(store Store) http.Handler { - server := &Server{store: store} - mux := http.NewServeMux() - mux.HandleFunc("/healthz", server.handleHealth) - mux.HandleFunc("/api/notes", server.handleCollection) - mux.HandleFunc("/api/notes/", server.handleItem) - return withCORS(mux) -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func (s *Server) handleCollection(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, s.store.List()) - case http.MethodPost: - var note Note - if err := json.NewDecoder(r.Body).Decode(¬e); err != nil { - writeError(w, http.StatusBadRequest, "invalid note json") - return - } - saved, err := s.store.Upsert(note) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusOK, saved) - default: - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - } -} - -func (s *Server) handleItem(w http.ResponseWriter, r *http.Request) { - id, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/api/notes/")) - if err != nil || strings.TrimSpace(id) == "" { - writeError(w, http.StatusBadRequest, "note id is required") - return - } - - switch r.Method { - case http.MethodPut: - var note Note - if err := json.NewDecoder(r.Body).Decode(¬e); err != nil { - writeError(w, http.StatusBadRequest, "invalid note json") - return - } - note.ID = id - saved, err := s.store.Upsert(note) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - writeJSON(w, http.StatusOK, saved) - case http.MethodDelete: - if err := s.store.Delete(id); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - w.WriteHeader(http.StatusNoContent) - default: - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - } -} - -func withCORS(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Accept") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} - -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) -} diff --git a/Server/services/keepernotes/pkg/notes/store.go b/Server/services/keepernotes/pkg/notes/store.go deleted file mode 100644 index 870537c0..00000000 --- a/Server/services/keepernotes/pkg/notes/store.go +++ /dev/null @@ -1,229 +0,0 @@ -package notes - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -type Note struct { - ID string `json:"id"` - Title string `json:"title"` - Body string `json:"body"` - BodyFormats []BodyFormat `json:"bodyFormats"` - Owner string `json:"owner"` - Collaborators []string `json:"collaborators"` - Color string `json:"color"` - UpdatedAt int64 `json:"updatedAt"` - SortOrder int64 `json:"sortOrder"` - Pinned bool `json:"pinned"` - Archived bool `json:"archived"` - Deleted bool `json:"deleted"` - Checklist bool `json:"checklist"` - Items []Item `json:"checklistItems"` - Labels []string `json:"labels"` - ReminderAt *int64 `json:"reminderAt"` - Images []Image `json:"images"` - Audios []Audio `json:"audios"` -} - -type Item struct { - ID string `json:"id"` - Text string `json:"text"` - Checked bool `json:"checked"` -} - -type BodyFormat struct { - Start int `json:"start"` - End int `json:"end"` - Style string `json:"style"` -} - -type Image struct { - ID string `json:"id"` - MimeType string `json:"mimeType"` - Base64 string `json:"base64"` -} - -type Audio struct { - ID string `json:"id"` - MimeType string `json:"mimeType"` - Base64 string `json:"base64"` - DurationMillis int64 `json:"durationMillis"` -} - -type Store interface { - List() []Note - Upsert(note Note) (Note, error) - Delete(id string) error -} - -type FileStore struct { - mu sync.Mutex - path string - notes map[string]Note -} - -func OpenFileStore(path string) (*FileStore, error) { - store := &FileStore{ - path: path, - notes: map[string]Note{}, - } - if err := store.load(); err != nil { - return nil, err - } - return store, nil -} - -func (s *FileStore) List() []Note { - s.mu.Lock() - defer s.mu.Unlock() - - return sortedNotes(s.notes) -} - -func (s *FileStore) Upsert(note Note) (Note, error) { - s.mu.Lock() - defer s.mu.Unlock() - - note.ID = strings.TrimSpace(note.ID) - if note.ID == "" { - return Note{}, errors.New("note id is required") - } - if note.Color == "" { - note.Color = "#FFF475" - } - if note.UpdatedAt <= 0 { - note.UpdatedAt = time.Now().UnixMilli() - } - if note.SortOrder <= 0 { - note.SortOrder = note.UpdatedAt - } - note.Owner = strings.TrimSpace(note.Owner) - note.Collaborators = normalizedStrings(note.Collaborators, 50) - - s.notes[note.ID] = note - if err := s.persistLocked(); err != nil { - return Note{}, err - } - return note, nil -} - -func (s *FileStore) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.notes, strings.TrimSpace(id)) - return s.persistLocked() -} - -func (s *FileStore) load() error { - dir := filepath.Dir(s.path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create data directory: %w", err) - } - - data, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - return s.persistLocked() - } - if err != nil { - return fmt.Errorf("read data file: %w", err) - } - if len(strings.TrimSpace(string(data))) == 0 { - return nil - } - - var loaded []Note - if err := json.Unmarshal(data, &loaded); err != nil { - return fmt.Errorf("decode data file: %w", err) - } - for _, note := range loaded { - if strings.TrimSpace(note.ID) == "" { - continue - } - s.notes[note.ID] = note - } - return nil -} - -func (s *FileStore) persistLocked() error { - notes := sortedNotes(s.notes) - dir := filepath.Dir(s.path) - tmp, err := os.CreateTemp(dir, ".keeper-notes-*.tmp") - if err != nil { - return fmt.Errorf("create temp data file: %w", err) - } - tmpName := tmp.Name() - defer os.Remove(tmpName) - - encoder := json.NewEncoder(tmp) - encoder.SetIndent("", " ") - if err := encoder.Encode(notes); err != nil { - _ = tmp.Close() - return fmt.Errorf("encode data file: %w", err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close temp data file: %w", err) - } - - if err := os.Rename(tmpName, s.path); err != nil { - _ = os.Remove(s.path) - if retryErr := os.Rename(tmpName, s.path); retryErr != nil { - return fmt.Errorf("replace data file: %w", retryErr) - } - } - return nil -} - -func normalizedStrings(values []string, limit int) []string { - seen := map[string]struct{}{} - normalized := []string{} - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - key := strings.ToLower(trimmed) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - normalized = append(normalized, trimmed) - if len(normalized) >= 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") - } -}