Compare commits

..

3 commits
main ... v6.0

469 changed files with 12177 additions and 327441 deletions

1
.github/FUNDING.yml vendored
View file

@ -1 +0,0 @@
ko_fi: philkes

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Android Version: [e.g. 13]
- App Version [e.g. 6.0]
**Additional context**
Add any other context about the problem here.

View file

@ -1,36 +0,0 @@
name: Bug Report
description: Create a report to help us improve
labels: ["bug"]
projects: []
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: input
id: version
attributes:
label: App Version
description: What version of the app are you running?
validations:
required: true
- type: input
id: android-version
attributes:
label: Android Version (API Level)
description: What Android version are you using?
- type: textarea
id: logs
attributes:
label: (Optional) Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!

View file

@ -1,10 +0,0 @@
---
name: Translation Update
about: Update translations by uploading updated translations.xlsx
title: '<INSERT LANGUAGE HERE> translations update'
labels: translations
assignees: ''
---
Drag'n'drop your updated translations.xlsx file here 🙂

View file

@ -1,56 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
paths:
- documentation/**
# Review gh actions docs if you want to further define triggers, paths, etc
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on
jobs:
build:
name: Build Docusaurus
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 18
cache-dependency-path: documentation/yarn.lock
cache: yarn
- name: Install dependencies
working-directory: documentation
run: yarn install --frozen-lockfile
- name: Build website
working-directory: documentation
run: yarn build
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@v3
with:
path: documentation/build
deploy:
name: Deploy to GitHub Pages
needs: build
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
# Deploy to the github-pages environment
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

7
.gitignore vendored
View file

@ -7,10 +7,3 @@
/captures
.externalNativeBuild
.cxx
*/.attach_pid*
fastlane/*
!fastlane/join-testers.png
!fastlane/metadata
Gemfile*
*.sh
!generate-changelogs.sh

View file

@ -1,25 +1,23 @@
#!/bin/sh
# Capture the list of initially staged Kotlin files
initial_staged_files=$(git diff --name-only --cached -- '*.kt')
# Count the number of staged Kotlin files
staged_files_count=$(git diff --name-only --cached --numstat -- '*.kt' | wc -l)
if [ -z "$initial_staged_files" ]; then
echo "No Kotlin files staged for commit."
exit 0
# Format only if there are Kotlin files in git's index
if [ "$staged_files_count" -gt 0 ]; then
# Format the staged Kotlin files and remove the "app/" prefix
formatted_files=$(git diff --name-only --cached -- '*.kt' | sed 's|^app/||' | paste -sd ",")
./gradlew ktfmtPrecommit --include-only="$formatted_files"
# Check if the formatting command was successful
if [ $? -ne 0 ]; then
echo "Kotlin formatting failed. Please fix the issues."
exit 1
fi
# Add the formatted Kotlin files to the staging area
git add -A $(git diff --name-only -- '*.kt')
echo "Kotlin files formatted and changes staged."
fi
formatted_files=$(echo "$initial_staged_files" | sed 's|^app/||' | paste -sd "," -)
echo "Formatting Kotlin files: $formatted_files"
./gradlew ktfmtPrecommit --include-only="$formatted_files"
if [ $? -ne 0 ]; then
echo "Kotlin formatting failed. Please fix the issues."
exit 1
fi
# Re-stage only the initially staged Kotlin files
for file in $initial_staged_files; do
git add "$file"
done
echo "Kotlin files formatted"
exit 0

View file

@ -1,35 +1,29 @@
@echo off
setlocal enabledelayedexpansion
rem Capture the list of initially staged Kotlin files
set "initial_staged_files="
for /f "delims=" %%f in ('git diff --name-only --cached -- "*.kt"') do (
set "initial_staged_files=!initial_staged_files! %%f,"
rem Count the number of staged Kotlin files
for /f %%i in ('git diff --name-only --cached --numstat -- "*.kt" ^| find /c /v ""') do set staged_files_count=%%i
rem Format only if there are Kotlin files in git's index
if %staged_files_count% gtr 0 (
rem Format the staged Kotlin files and remove the "app/" prefix
for /f "delims=" %%f in ('git diff --name-only --cached -- "*.kt" ^| sed "s|^app/||"') do (
set formatted_files=%%f
set formatted_files=!formatted_files!, %%f
)
rem Remove the trailing comma if necessary
set formatted_files=%formatted_files:~, -1%
call gradlew ktfmtPrecommit --include-only="%formatted_files%"
rem Check if the formatting command was successful
if errorlevel 1 (
echo Kotlin formatting failed. Please fix the issues.
exit /b 1
)
rem Add the formatted Kotlin files to the staging area
git add -A git diff --name-only -- "*.kt"
echo Kotlin files formatted and changes staged.
)
rem Check if there are any staged Kotlin files
if "%initial_staged_files%"=="" (
echo No Kotlin files staged for commit.
exit /b 0
)
rem Remove the trailing comma from the list of formatted files
set "formatted_files=%initial_staged_files:~0,-1%"
echo Formatting Kotlin files: %formatted_files%
call gradlew ktfmtPrecommit --include-only="%formatted_files%"
rem Check if the formatting command was successful
if errorlevel 1 (
echo Kotlin formatting failed. Please fix the issues.
exit /b 1
)
rem Re-stage only the initially staged Kotlin files
for %%f in (%initial_staged_files%) do (
git add "%%f"
)
echo Kotlin files formatted
exit /b 0
exit /b 0

View file

@ -1,371 +0,0 @@
# Changelog
## [v7.4.0](https://github.com/PhilKes/NotallyX/tree/v7.4.0) (2025-04-18)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.3.1...v7.4.0)
### Added Features
- Don't force capitalization when adding a label [\#532](https://github.com/PhilKes/NotallyX/issues/532)
- Add a screen protection against screenshot attempts [\#386](https://github.com/PhilKes/NotallyX/issues/386)
### Fixed Bugs
- Share pure text note error [\#544](https://github.com/PhilKes/NotallyX/issues/544)
- Crash when deleting checked items in a list [\#539](https://github.com/PhilKes/NotallyX/issues/539)
- Keyboard don't open after closing it on Android 7 [\#537](https://github.com/PhilKes/NotallyX/issues/537)
- Unable to open links before changing view mode [\#527](https://github.com/PhilKes/NotallyX/issues/527)
- Reminder popup cut on small screens [\#522](https://github.com/PhilKes/NotallyX/issues/522)
- Auto Backup failed [\#514](https://github.com/PhilKes/NotallyX/issues/514)
## [v7.3.1](https://github.com/PhilKes/NotallyX/tree/v7.3.1) (2025-04-08)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.3.0...v7.3.1)
### Fixed Bugs
- Button to close note search doesn't work [\#519](https://github.com/PhilKes/NotallyX/issues/519)
- app crashes when pressing label [\#517](https://github.com/PhilKes/NotallyX/issues/517)
## [v7.3.0](https://github.com/PhilKes/NotallyX/tree/v7.3.0) (2025-04-07)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.1...v7.3.0)
### Added Features
- Persist viewMode of each note individually [\#497](https://github.com/PhilKes/NotallyX/issues/497)
- Read-only mode by default and new notes [\#495](https://github.com/PhilKes/NotallyX/issues/495)
- Hide notes based on labels [\#401](https://github.com/PhilKes/NotallyX/issues/401)
- An archived note should be visible in its label's folder. [\#398](https://github.com/PhilKes/NotallyX/issues/398)
- Sharing notes from the app [\#380](https://github.com/PhilKes/NotallyX/issues/380)
- Add support for json notes import [\#377](https://github.com/PhilKes/NotallyX/issues/377)
- Sharing images to the app [\#281](https://github.com/PhilKes/NotallyX/issues/281)
- Strikethrough checked items lists [\#250](https://github.com/PhilKes/NotallyX/issues/250)
- Click on list element to check it [\#248](https://github.com/PhilKes/NotallyX/issues/248)
- Add long press actions to undo/redo buttons [\#244](https://github.com/PhilKes/NotallyX/issues/244)
- Convert Note \<=\> List [\#190](https://github.com/PhilKes/NotallyX/issues/190)
- Edit labels inside notes [\#180](https://github.com/PhilKes/NotallyX/issues/180)
- Support Wallpaper color themes [\#175](https://github.com/PhilKes/NotallyX/issues/175)
- View Mode [\#76](https://github.com/PhilKes/NotallyX/issues/76)
### Fixed Bugs
- Android 7.0 Navigation bar color issue [\#515](https://github.com/PhilKes/NotallyX/issues/515)
- Search Mode loop for Android \< 9.0 [\#508](https://github.com/PhilKes/NotallyX/issues/508)
- BaseNote.viewMode database migration error [\#505](https://github.com/PhilKes/NotallyX/issues/505)
- New list items can't be added with linebreak/enter [\#496](https://github.com/PhilKes/NotallyX/issues/496)
- Undo changes more than the last changed character [\#472](https://github.com/PhilKes/NotallyX/issues/472)
- Auto Backup failed [\#468](https://github.com/PhilKes/NotallyX/issues/468)
- Amount of backups to keep in periodic backups are not respected if nextcloud mount is used. [\#133](https://github.com/PhilKes/NotallyX/issues/133)
## [v7.2.1](https://github.com/PhilKes/NotallyX/tree/v7.2.1) (2025-03-18)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.0...v7.2.1)
### Added Features
- Note not automatically saved when App is killed by system [\#446](https://github.com/PhilKes/NotallyX/issues/446)
### Fixed Bugs
- Auto Backup failed [\#456](https://github.com/PhilKes/NotallyX/issues/456)
## [v7.2.1](https://github.com/PhilKes/NotallyX/tree/v7.2.1) (2025-03-18)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.2.0...v7.2.1)
### Added Features
- Note not automatically saved when App is killed by system [\#446](https://github.com/PhilKes/NotallyX/issues/446)
### Fixed Bugs
- Auto Backup failed [\#456](https://github.com/PhilKes/NotallyX/issues/456)
## [v7.2.0](https://github.com/PhilKes/NotallyX/tree/v7.2.0) (2025-03-08)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.1.0...v7.2.0)
### Added Features
- Sort notes by color [\#442](https://github.com/PhilKes/NotallyX/issues/442)
### Fixed Bugs
- Unable to locate the 'Uncheck all' option [\#444](https://github.com/PhilKes/NotallyX/issues/444)
- List crash when last unchecked item moved [\#436](https://github.com/PhilKes/NotallyX/issues/436)
- Pasting multi line text in empty lists crash [\#434](https://github.com/PhilKes/NotallyX/issues/434)
- Quickly tapping delete button crash [\#428](https://github.com/PhilKes/NotallyX/issues/428)
- First list item can keep parent property [\#427](https://github.com/PhilKes/NotallyX/issues/427)
- \(List\) Move last unchecked item above parent bug [\#425](https://github.com/PhilKes/NotallyX/issues/425)
- Dragging child item to same position breaks parent association [\#422](https://github.com/PhilKes/NotallyX/issues/422)
- Disabling list auto sorting crash [\#421](https://github.com/PhilKes/NotallyX/issues/421)
- Crash while indenting a checklist item [\#419](https://github.com/PhilKes/NotallyX/issues/419)
- List swipe as subtask crash [\#418](https://github.com/PhilKes/NotallyX/issues/418)
- List crash [\#413](https://github.com/PhilKes/NotallyX/issues/413)
- Checked parent + subtask places between item [\#410](https://github.com/PhilKes/NotallyX/issues/410)
- App crashed while screen was off [\#408](https://github.com/PhilKes/NotallyX/issues/408)
- Unchecked items can't be deleted [\#407](https://github.com/PhilKes/NotallyX/issues/407)
- Some list items can't be set to subtask after unchecked [\#406](https://github.com/PhilKes/NotallyX/issues/406)
- List items deleted [\#405](https://github.com/PhilKes/NotallyX/issues/405)
- List item parent task becomes subtask [\#404](https://github.com/PhilKes/NotallyX/issues/404)
- Check empty item crash [\#403](https://github.com/PhilKes/NotallyX/issues/403)
- Drag problem in long checklist [\#396](https://github.com/PhilKes/NotallyX/issues/396)
- List swap items bug [\#395](https://github.com/PhilKes/NotallyX/issues/395)
- Background crashes [\#323](https://github.com/PhilKes/NotallyX/issues/323)
## [v7.1.0](https://github.com/PhilKes/NotallyX/tree/v7.1.0) (2025-02-20)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v7.0.0...v7.1.0)
### Added Features
- Dark mode for note colors [\#352](https://github.com/PhilKes/NotallyX/issues/352)
- Add "new color" option when deleting a color [\#347](https://github.com/PhilKes/NotallyX/issues/347)
- Make "Start view" as default view [\#339](https://github.com/PhilKes/NotallyX/issues/339)
- Display first item as title for lists without title [\#317](https://github.com/PhilKes/NotallyX/issues/317)
- Remove delete option from top bar since it's already in the bottom more menu [\#316](https://github.com/PhilKes/NotallyX/issues/316)
- Add "Export" to bottom menu [\#315](https://github.com/PhilKes/NotallyX/issues/315)
- Move "Hide labels" switch up below the labels slider [\#311](https://github.com/PhilKes/NotallyX/issues/311)
- Display widgets with the notes' colors [\#300](https://github.com/PhilKes/NotallyX/issues/300)
- Allow Pinning a Specific Label as the Starting Page [\#269](https://github.com/PhilKes/NotallyX/issues/269)
- Move checked / unchecked items in list [\#251](https://github.com/PhilKes/NotallyX/issues/251)
- Moving all labels to the sidebar [\#240](https://github.com/PhilKes/NotallyX/issues/240)
- Add "no label" category [\#219](https://github.com/PhilKes/NotallyX/issues/219)
- Manual color selection [\#187](https://github.com/PhilKes/NotallyX/issues/187)
- Pure Dark Mode [\#16](https://github.com/PhilKes/NotallyX/issues/16)
### Fixed Bugs
- Moving group of task at first position wrong order [\#392](https://github.com/PhilKes/NotallyX/issues/392)
- Parent task not checked in specific case [\#391](https://github.com/PhilKes/NotallyX/issues/391)
- Add auto-backup no error notification [\#381](https://github.com/PhilKes/NotallyX/issues/381)
- Backup on save setting not restored [\#373](https://github.com/PhilKes/NotallyX/issues/373)
- Start View setting not restored [\#367](https://github.com/PhilKes/NotallyX/issues/367)
- \(List\) Child item can't be place after the item below it [\#362](https://github.com/PhilKes/NotallyX/issues/362)
- Android 9 Crash java.lang.NoSuchMethodError: getTextSelectHandleLeft\(\) [\#358](https://github.com/PhilKes/NotallyX/issues/358)
- List items order bug [\#357](https://github.com/PhilKes/NotallyX/issues/357)
- Lists parent-child items crash [\#356](https://github.com/PhilKes/NotallyX/issues/356)
- List new items wrong position [\#354](https://github.com/PhilKes/NotallyX/issues/354)
- Replacement color message title limited to 2 lines [\#348](https://github.com/PhilKes/NotallyX/issues/348)
- \(Lists\) Delete checked items and undo crash [\#331](https://github.com/PhilKes/NotallyX/issues/331)
- Sort List items strange bug [\#330](https://github.com/PhilKes/NotallyX/issues/330)
- Backup folder re-select prompt closes automatically [\#324](https://github.com/PhilKes/NotallyX/issues/324)
- Biometric lock can't be enabled [\#259](https://github.com/PhilKes/NotallyX/issues/259)
## [v7.0.0](https://github.com/PhilKes/NotallyX/tree/v7.0.0) (2025-01-27)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.4.1...v7.0.0)
### Added Features
- Add Reminders menu to navigation panel [\#294](https://github.com/PhilKes/NotallyX/issues/294)
- Extend notes colors to full screen [\#264](https://github.com/PhilKes/NotallyX/issues/264)
- Auto-backup on note modification [\#203](https://github.com/PhilKes/NotallyX/issues/203)
- Option to show full date inside the note regardless of chosen date format [\#111](https://github.com/PhilKes/NotallyX/issues/111)
- Reminder for Notes [\#85](https://github.com/PhilKes/NotallyX/issues/85)
### Fixed Bugs
- Restoring settings and backup folder [\#310](https://github.com/PhilKes/NotallyX/issues/310)
- Settings can't be imported [\#307](https://github.com/PhilKes/NotallyX/issues/307)
- Label create/edit dialog broken [\#302](https://github.com/PhilKes/NotallyX/issues/302)
- Rotating screen moves the cursor to the end of note [\#293](https://github.com/PhilKes/NotallyX/issues/293)
- Creating an empty note corrupts auto backup archive [\#288](https://github.com/PhilKes/NotallyX/issues/288)
- Changes to check lists \(checking items and removing items\) reverts when screen rotatates [\#287](https://github.com/PhilKes/NotallyX/issues/287)
- Auto-backups stop exporting after set limit is reached [\#270](https://github.com/PhilKes/NotallyX/issues/270)
- Link is not saved if it's the last edit [\#267](https://github.com/PhilKes/NotallyX/issues/267)
- Labels hidden in overview not applied when importing JSON [\#266](https://github.com/PhilKes/NotallyX/issues/266)
- An unexpected error occurred. Sorry for the inconvenience [\#262](https://github.com/PhilKes/NotallyX/issues/262)
- List subtasks bugs [\#207](https://github.com/PhilKes/NotallyX/issues/207)
- Remove link also remove text [\#201](https://github.com/PhilKes/NotallyX/issues/201)
- Biometric Lock crash [\#177](https://github.com/PhilKes/NotallyX/issues/177)
- Import from Evernote and Google Keep not working [\#134](https://github.com/PhilKes/NotallyX/issues/134)
## [v6.4.1](https://github.com/PhilKes/NotallyX/tree/v6.4.1) (2025-01-17)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.4.0...v6.4.1)
### Added Features
- Bottom AppBar cutoff + chinese translations [\#176](https://github.com/PhilKes/NotallyX/issues/176)
### Fixed Bugs
- Crash loop when enabling biometrics [\#256](https://github.com/PhilKes/NotallyX/issues/256)
- Crash when creating notes in 6.4.0 [\#255](https://github.com/PhilKes/NotallyX/issues/255)
## [v6.4.0](https://github.com/PhilKes/NotallyX/tree/v6.4.0) (2025-01-17)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.3.0...v6.4.0)
### Added Features
- Prevent word wrap in titles [\#220](https://github.com/PhilKes/NotallyX/issues/220)
- Move more top menu to bottom appbar [\#206](https://github.com/PhilKes/NotallyX/issues/206)
- App settings backup [\#204](https://github.com/PhilKes/NotallyX/issues/204)
- Add a touch bar to scroll quickly [\#202](https://github.com/PhilKes/NotallyX/issues/202)
- Select all notes menu button [\#186](https://github.com/PhilKes/NotallyX/issues/186)
- One line entries in main menu [\#185](https://github.com/PhilKes/NotallyX/issues/185)
- Some suggestions [\#183](https://github.com/PhilKes/NotallyX/issues/183)
### Fixed Bugs
- Cutting text during search causes app crash [\#245](https://github.com/PhilKes/NotallyX/issues/245)
- Long notes can't be shared [\#243](https://github.com/PhilKes/NotallyX/issues/243)
- Trigger disable/enable dataOnExternalStorage on settings import [\#231](https://github.com/PhilKes/NotallyX/issues/231)
- Fast scrollbar in notes overview lags [\#230](https://github.com/PhilKes/NotallyX/issues/230)
- Handle user unenrolling device biometrics while biometric lock is enabled [\#229](https://github.com/PhilKes/NotallyX/issues/229)
- Crash on import if no file explorer app installed [\#227](https://github.com/PhilKes/NotallyX/issues/227)
- Assign label inactive checkboxes [\#221](https://github.com/PhilKes/NotallyX/issues/221)
- List icon bug in main view [\#215](https://github.com/PhilKes/NotallyX/issues/215)
- Import plain text issues [\#209](https://github.com/PhilKes/NotallyX/issues/209)
- "Auto backup period in days" cursor buggy [\#192](https://github.com/PhilKes/NotallyX/issues/192)
- Unable to view full link while editing [\#181](https://github.com/PhilKes/NotallyX/issues/181)
- Scrollbar missing ... [\#178](https://github.com/PhilKes/NotallyX/issues/178)
- Widget is invisible when placed [\#156](https://github.com/PhilKes/NotallyX/issues/156)
**Closed issues:**
- Items in Lists [\#184](https://github.com/PhilKes/NotallyX/issues/184)
## [v6.3.0](https://github.com/PhilKes/NotallyX/tree/v6.3.0) (2024-12-23)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.2...v6.3.0)
### Added Features
- Possibility to directly report bug when crash occurs [\#170](https://github.com/PhilKes/NotallyX/issues/170)
- "Link Note" text for notes without a title [\#166](https://github.com/PhilKes/NotallyX/issues/166)
- Improve app theme's color contrasts [\#163](https://github.com/PhilKes/NotallyX/issues/163)
- Paste text containing link does not convert to clickable link + polish translation [\#157](https://github.com/PhilKes/NotallyX/issues/157)
- Bottom navigation to increase accessibility for one handed usage [\#129](https://github.com/PhilKes/NotallyX/issues/129)
- Search in selected note [\#108](https://github.com/PhilKes/NotallyX/issues/108)
### Fixed Bugs
- App crashes when enabling biometric lock [\#168](https://github.com/PhilKes/NotallyX/issues/168)
- Barely visible Navigation buttons on Motorola devices in Light Theme [\#161](https://github.com/PhilKes/NotallyX/issues/161)
## [v6.2.2](https://github.com/PhilKes/NotallyX/tree/v6.2.2) (2024-12-09)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.1...v6.2.2)
### Added Features
- Encrypted backups [\#151](https://github.com/PhilKes/NotallyX/issues/151)
- Parse list items when pasting a list formatted text into a list note [\#150](https://github.com/PhilKes/NotallyX/issues/150)
- Chinese display of options interface [\#149](https://github.com/PhilKes/NotallyX/issues/149)
### Fixed Bugs
- List notes deleting item even though text is not empty on backspace pressed [\#142](https://github.com/PhilKes/NotallyX/issues/142)
## [v6.2.1](https://github.com/PhilKes/NotallyX/tree/v6.2.1) (2024-12-06)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.2.0...v6.2.1)
### Added Features
- Add change color option inside of a Note [\#140](https://github.com/PhilKes/NotallyX/issues/140)
- Let user choose whether add items in a todo-list at the bottom or top of already existing items [\#132](https://github.com/PhilKes/NotallyX/issues/132)
- Migrate Theme to Material 3 [\#104](https://github.com/PhilKes/NotallyX/issues/104)
### Fixed Bugs
- Exporting multiple notes with same title overwrites files [\#144](https://github.com/PhilKes/NotallyX/issues/144)
- Single notes export as "Untitled.txt" [\#143](https://github.com/PhilKes/NotallyX/issues/143)
## [v6.2.0](https://github.com/PhilKes/NotallyX/tree/v6.2.0) (2024-12-03)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.1.2...v6.2.0)
### Added Features
- Replace positions of "Link Note" and "Select all" [\#136](https://github.com/PhilKes/NotallyX/issues/136)
- Allow to add Notes in Label View [\#128](https://github.com/PhilKes/NotallyX/issues/128)
- Empty notes deleted forever [\#118](https://github.com/PhilKes/NotallyX/issues/118)
- Sync devices using Syncthing [\#109](https://github.com/PhilKes/NotallyX/issues/109)
- Import from txt files [\#103](https://github.com/PhilKes/NotallyX/issues/103)
- Add more Export formats [\#62](https://github.com/PhilKes/NotallyX/issues/62)
### Fixed Bugs
- Using delete option from inside a note won't delete the note [\#131](https://github.com/PhilKes/NotallyX/issues/131)
- The app crashes when creating a link [\#112](https://github.com/PhilKes/NotallyX/issues/112)
- Pinning does not work [\#110](https://github.com/PhilKes/NotallyX/issues/110)
## [v6.1.2](https://github.com/PhilKes/NotallyX/tree/v6.1.2) (2024-11-19)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.1.1...v6.1.2)
### Fixed Bugs
- F-Droid can't build [\#126](https://github.com/PhilKes/NotallyX/issues/126)
- Undo in list can be quite destructive [\#125](https://github.com/PhilKes/NotallyX/issues/125)
- Actions like pin/label/archive only work from overview, not individual note/list [\#124](https://github.com/PhilKes/NotallyX/issues/124)
- Jumbled notes after opening settings [\#100](https://github.com/PhilKes/NotallyX/issues/100)
## [v6.1.1](https://github.com/PhilKes/NotallyX/tree/v6.1.1) (2024-11-14)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/v6.0...v6.1.1)
### Added Features
- Show delete option in top bar after selecting a note [\#105](https://github.com/PhilKes/NotallyX/issues/105)
- Clear previous search term by default [\#96](https://github.com/PhilKes/NotallyX/issues/96)
- Color the navigation bar [\#95](https://github.com/PhilKes/NotallyX/issues/95)
- Make multiselection easier [\#91](https://github.com/PhilKes/NotallyX/issues/91)
- Auto Discard Empty Notes [\#86](https://github.com/PhilKes/NotallyX/issues/86)
- Pin/label multiple Notes [\#78](https://github.com/PhilKes/NotallyX/issues/78)
- Hide widget notes when app locked. [\#75](https://github.com/PhilKes/NotallyX/issues/75)
- Creating categories [\#72](https://github.com/PhilKes/NotallyX/issues/72)
- Add Search Bar to Archived and Deleted Notes Sections [\#68](https://github.com/PhilKes/NotallyX/issues/68)
- Ability to swipe ListItem on DragHandle [\#67](https://github.com/PhilKes/NotallyX/issues/67)
- Show Last Modified Dates in Notes [\#60](https://github.com/PhilKes/NotallyX/issues/60)
- Import from major note apps [\#53](https://github.com/PhilKes/NotallyX/issues/53)
- Display Labels in NavigationView [\#49](https://github.com/PhilKes/NotallyX/issues/49)
- Unneeded Notification Permission on Image Upload [\#34](https://github.com/PhilKes/NotallyX/issues/34)
- Linking Notes Within Notes [\#32](https://github.com/PhilKes/NotallyX/issues/32)
### Fixed Bugs
- Deleted notes won't "delete forever" [\#117](https://github.com/PhilKes/NotallyX/issues/117)
- Empty Auto Backup [\#101](https://github.com/PhilKes/NotallyX/issues/101)
- Selector Moves Left in Widget When Selecting Untitled Notes [\#63](https://github.com/PhilKes/NotallyX/issues/63)
## [v6.0](https://github.com/PhilKes/NotallyX/tree/v6.0) (2024-10-28)
[Full Changelog](https://github.com/PhilKes/NotallyX/compare/a29bff9a2d1adcbea47cb024ab21426bd678c016...v6.0)
### Added Features
- Change icon and add monochrome icon [\#44](https://github.com/PhilKes/NotallyX/issues/44)
- Improve copy&paste behaviour [\#40](https://github.com/PhilKes/NotallyX/issues/40)
- Option to Change a Note in Widget [\#36](https://github.com/PhilKes/NotallyX/issues/36)
- Improve auto-backups [\#31](https://github.com/PhilKes/NotallyX/issues/31)
- Lock Notes via PIN/Fingerprint [\#30](https://github.com/PhilKes/NotallyX/issues/30)
- More options for sorting Notes in Overview [\#29](https://github.com/PhilKes/NotallyX/issues/29)
- Support subtasks in Widgets \(for list notes\) [\#6](https://github.com/PhilKes/NotallyX/issues/6)
- Improving Image Display on Notes [\#15](https://github.com/PhilKes/NotallyX/issues/15)
- File attachment [\#9](https://github.com/PhilKes/NotallyX/issues/9)
- Highlighting Completed Tasks in widget [\#17](https://github.com/PhilKes/NotallyX/issues/17)
- Encrypt backups [\#18](https://github.com/PhilKes/NotallyX/issues/18)
- Undo deleting/archiving notes [\#19](https://github.com/PhilKes/NotallyX/issues/19)
### Fixed Bugs
- Title Change Doesnt Update Last Modified Date [\#61](https://github.com/PhilKes/NotallyX/issues/61)
- Biometric Lock Improvement [\#58](https://github.com/PhilKes/NotallyX/issues/58)
- App crashes when pin lock is enabled [\#50](https://github.com/PhilKes/NotallyX/issues/50)
- Undo action ignored Spans [\#47](https://github.com/PhilKes/NotallyX/issues/47)
- Crash After Importing a Note [\#13](https://github.com/PhilKes/NotallyX/issues/13)
- Tasks Disappear When Changing App Language [\#4](https://github.com/PhilKes/NotallyX/issues/4)
- Unable to Swipe Back After Adding Tasks [\#5](https://github.com/PhilKes/NotallyX/issues/5)
- App Crash When Importing Notes and Opening a Task Note [\#7](https://github.com/PhilKes/NotallyX/issues/7)
- improving subtasks [\#8](https://github.com/PhilKes/NotallyX/issues/8)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View file

@ -1,25 +0,0 @@
## Privacy Policy
This privacy policy applies to the NotallyX app (hereby referred to as "Application") for mobile devices that was created as an Open Source service. This service is intended for use "AS IS".
### What information does the Application obtain and how is it used?
The Application does not obtain any information when you download and use it. Registration is not required to use the Application.
### Does the Application collect precise real time location information of the device?
This Application does not collect precise information about the location of your mobile device.
### Do third parties see and/or have access to information obtained by the Application?
Since the Application does not collect any information, no data is shared with third parties.
### Your Consent
By using the Application, you are consenting to this Privacy Policy now and as amended by the developer.
### Contact Us
If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact us via email at philkeyplaystore@gmail.com.
This privacy policy is effective as of 2024-10-30

1
Privacy-Policy.txt Normal file
View file

@ -0,0 +1 @@
No user data is collected

View file

@ -2,13 +2,8 @@
<img src="fastlane/metadata/android/en-US/images/icon.png" alt="icon" width="90"/>
<br />
<b>NotallyX | Minimalistic note taking app</b>
<p>
<center>
<a href='https://play.google.com/store/apps/details?id=com.philkes.notallyx&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' height='80'/></a>
<a href="https://f-droid.org/en/packages/com.philkes.notallyx"><img alt='IzzyOnDroid' height='80' src='https://fdroid.gitlab.io/artwork/badge/get-it-on.png' /></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.philkes.notallyx"><img alt='F-Droid' height='80' src='https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png' /></a>
</center>
</p>
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="70"/>](https://f-droid.org/packages/com.philkes.notallyx/)
</h2>
<div style="display: flex; justify-content: space-between; width: 100%;">
@ -27,8 +22,7 @@
[Notally](https://github.com/OmGodse/Notally), but eXtended
* Create **rich text** notes with support for bold, italics, mono space and strike-through
* Create **task lists** and order them with subtasks (+ auto-sort checked items to the end)
* Set **reminders** with notifications for important notes
* Create **task lists** and order them with subtasks
* Complement your notes with any type of file such as **pictures**, PDFs, etc.
* **Sort notes** by title, last modified date, creation date
* **Color, pin and label** your notes for quick organisation
@ -37,7 +31,6 @@
* Use **Home Screen Widget** to access important notes fast
* **Lock your notes via Biometric/PIN**
* Configurable **auto-backups**
* Create quick audio notes
* Display the notes either in a **List or Grid**
* Quickly share notes by text
* Extensive preferences to adjust views to your liking
@ -45,44 +38,21 @@
* Adaptive android app icon
* Support for Lollipop devices and up
---
### Bug Reports / Feature-Requests
If you find any bugs or want to propose a new Feature/Enhancement, feel free to [create a new Issue](https://github.com/PhilKes/NotallyX/issues/new/choose)
When using the app and an unknown error occurs, causing the app to crash you will see a dialog (see showcase video in https://github.com/PhilKes/NotallyX/pull/171) from which you can immediately create a bug report on Github with the crash details pre-filled.
#### Beta Releases
I occasionally release BETA versions of the app during development, since its very valuable for me to get feedback before publicly releasing a new version.
These BETA releases have another `applicationId` as the release versions, thats why when you install a BETA version it will show up on your device as a separate app called `NotallyX BETA`.
BETA versions also have their own data, they do not use the data of your NotallyX app
You can download the most recent BETA release [here on Github](https://github.com/PhilKes/NotallyX/releases/tag/beta)
### Translations
All translations are crowd sourced.
To contribute:
1. Download current [translations.xlsx](https://github.com/PhilKes/NotallyX/raw/refs/heads/main/app/translations.xlsx)
2. Open in Excel/LibreOffice and add missing translations
Notes:
- Missing translations are marked in red
- You can filter by key or any language column values
- Non-Translatable strings are hidden and marked in gray, do not add translations for them
- For plurals, some languages need/have more quantity strings than others, if a quantity string in the default language (english) is not needed the row is highlighted in yellow. If your language does not need that quantity string either, ignore them.
3. Open a [Update Translations Issue](https://github.com/PhilKes/NotallyX/issues/new?assignees=&labels=translations&projects=&template=translation.md&title=%3CINSERT+LANGUAGE+HERE%3E+translations+update)
4. I will create a Pull-Request to add your updated translations
All translations are crowd sourced. To contribute, follow these [guidelines](https://m2.material.io/design/communication/writing.html) and open a pull request.
See [Android Translations Converter](https://github.com/PhilKes/android-translations-converter-plugin) for more details
### Contributing
If you find any bugs or want to propose a new Feature/Enhancement, feel free to [create a new Issue](https://github.com/PhilKes/NotallyX/issues/new)
If you would like to contribute code yourself, just grab any open issue (that has no other developer assigned yet), leave a comment that you want to work on it and start developing by forking this repo.
The project is a default Android project written in Kotlin, I highly recommend using Android Studio for development. Also be sure to test your changes with an Android device/emulator that uses the same Android SDK Version as defined in the `build.gradle` `targetSdk`.
Before submitting your proposed changes as a Pull-Request, make sure all tests are still working (`./gradlew test`), and run `./gradlew ktfmtFormat` for common formatting (also executed automatically as pre-commit hook).
If you would like to contribute code yourself, just grab any open issue (that has no other developer assigned yet) and start working.
The project is a default Android project written in Kotlin.
Before submitting your proposed changes as a Pull-Request, make sure all tests are still working, and run `./gradlew ktfmtFormat` for common formatting.
### Attribution
The original Notally project was developed by [OmGodse](https://github.com/OmGodse) under the [GPL 3.0 License](https://github.com/OmGodse/Notally/blob/master/LICENSE.md).
In accordance to GPL 3.0, this project is licensed under the same [GPL 3.0 License](https://github.com/PhilKes/NotallyX/blob/master/LICENSE.md).
In accordance to GPL 3.0, this project is licensed under the same [GPL 3.0 License](https://github.com/PhilKes/NotallyX/blob/master/LICENSE.md).

119
app/build.gradle Normal file
View file

@ -0,0 +1,119 @@
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
id 'com.ncorti.ktfmt.gradle' version '0.20.1'
}
android {
compileSdk 34
namespace 'com.philkes.notallyx'
defaultConfig {
applicationId 'com.philkes.notallyx'
minSdk 21
targetSdk 33
versionCode 60
versionName "6.0"
resourceConfigurations += ['en', 'ca', 'cs', 'da', 'de', 'el', 'es', 'fr', 'hu', 'in', 'it', 'ja', 'my', 'nb', 'nl', 'nn', 'pl', 'pt-rBR', 'pt-rPT', 'ro', 'ru', 'sk', 'sv', 'tl', 'tr', 'uk', 'vi', 'zh-rCN']
vectorDrawables.generatedDensities = []
}
ksp {
arg("room.generateKotlin", "true")
arg("room.schemaLocation", "$projectDir/schemas")
}
buildTypes {
debug {
applicationIdSuffix ".debug"
}
release {
crunchPngs false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
kotlinOptions { jvmTarget = "1.8" }
buildFeatures { viewBinding true }
packagingOptions.resources {
excludes += ["DebugProbesKt.bin", "META-INF/**.version", "kotlin/**.kotlin_builtins", "kotlin-tooling-metadata.json"]
}
}
tasks.withType(KotlinCompile).configureEach {
kotlinOptions.jvmTarget = "1.8"
}
ktfmt {
kotlinLangStyle()
}
tasks.register('ktfmtPrecommit', KtfmtFormatTask) {
source = project.fileTree(rootDir)
include("**/*.kt")
}
tasks.register('installLocalGitHooks', Copy) {
def scriptsDir = new File(rootProject.rootDir, '.scripts/')
def hooksDir = new File(rootProject.rootDir, '.git/hooks')
from(scriptsDir) {
include 'pre-commit', 'pre-commit.bat'
}
into { hooksDir }
inputs.files(file("${scriptsDir}/pre-commit"), file("${scriptsDir}/pre-commit.bat"))
outputs.dir(hooksDir)
fileMode 0775
}
preBuild.dependsOn installLocalGitHooks
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'junit:junit:4.13.2'
final def navVersion = "2.3.5"
final def roomVersion = "2.6.1"
ksp "androidx.room:room-compiler:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "net.zetetic:android-database-sqlcipher:4.5.3"
implementation "androidx.sqlite:sqlite-ktx:2.4.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.1"
//noinspection GradleDependency
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
//noinspection GradleDependency
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "org.ocpsoft.prettytime:prettytime:4.0.6.Final"
implementation "com.google.android.material:material:1.12.0"
implementation 'com.github.zerobranch:SwipeLayout:1.3.1'
implementation "com.github.bumptech.glide:glide:4.15.1"
implementation "com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0"
testImplementation "junit:junit:4.13.2"
testImplementation "androidx.test:core:1.6.1"
testImplementation "org.mockito:mockito-core:5.13.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:5.4.0"
testImplementation "io.mockk:mockk:1.13.12"
testImplementation "org.json:json:20180813"
}

View file

@ -1,223 +0,0 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.apache.commons.configuration2.PropertiesConfiguration
import org.apache.commons.configuration2.io.FileHandler
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
id("com.google.devtools.ksp")
id("com.ncorti.ktfmt.gradle") version "0.20.1"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
id("io.github.philkes.android-translations-converter") version "1.0.5"
}
android {
namespace = "com.philkes.notallyx"
compileSdk = 34
ndkVersion = "29.0.13113456"
defaultConfig {
applicationId = "com.philkes.notallyx"
minSdk = 21
targetSdk = 34
versionCode = project.findProperty("app.versionCode").toString().toInt()
versionName = project.findProperty("app.versionName").toString()
resourceConfigurations += listOf(
"en", "ca", "cs", "da", "de", "el", "es", "fr", "hu", "in", "it", "ja", "my", "nb", "nl", "nn", "pl", "pt-rBR", "pt-rPT", "ro", "ru", "sk", "sv", "tl", "tr", "uk", "vi", "zh-rCN", "zh-rTW"
)
vectorDrawables.generatedDensities?.clear()
ndk {
debugSymbolLevel= "FULL"
}
}
ksp {
arg("room.generateKotlin", "true")
arg("room.schemaLocation", "$projectDir/schemas")
}
signingConfigs {
create("release") {
storeFile = file(providers.gradleProperty("RELEASE_STORE_FILE").get())
storePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").get()
keyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").get()
keyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").get()
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
resValue("string", "app_name", "NotallyX DEBUG")
}
release {
isCrunchPngs = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
create("beta"){
initWith(getByName("release"))
applicationIdSuffix = ".beta"
versionNameSuffix = "-BETA"
resValue("string", "app_name", "NotallyX BETA")
}
}
applicationVariants.all {
this.outputs
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
.forEach { output ->
output.outputFileName = "NotallyX-$versionName.apk"
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
kotlinOptions {
jvmTarget = "1.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
buildFeatures {
viewBinding = true
}
packaging {
resources.excludes += listOf(
"DebugProbesKt.bin",
"META-INF/**.version",
"kotlin/**.kotlin_builtins",
"kotlin-tooling-metadata.json"
)
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
ktfmt {
kotlinLangStyle()
}
tasks.register<KtfmtFormatTask>("ktfmtPrecommit") {
source = project.fileTree(rootDir)
include("**/*.kt")
}
tasks.register<Copy>("installLocalGitHooks") {
val scriptsDir = File(rootProject.rootDir, ".scripts/")
val hooksDir = File(rootProject.rootDir, ".git/hooks")
from(scriptsDir) {
include("pre-commit", "pre-commit.bat")
}
into(hooksDir)
inputs.files(file("${scriptsDir}/pre-commit"), file("${scriptsDir}/pre-commit.bat"))
outputs.dir(hooksDir)
fileMode = 509 // 0775 octal in decimal
// If this throws permission denied:
// chmod +rwx ./.git/hooks/pre-commit*
}
tasks.preBuild.dependsOn(tasks.named("installLocalGitHooks"), tasks.exportTranslationsToExcel)
tasks.register("generateChangelogs") {
doLast {
val githubToken = providers.gradleProperty("CHANGELOG_GITHUB_TOKEN").orNull
val command = mutableListOf(
"bash",
rootProject.file("generate-changelogs.sh").absolutePath,
"v${project.findProperty("app.lastVersionName").toString()}",
rootProject.file("CHANGELOG.md").absolutePath
)
if (!githubToken.isNullOrEmpty()) {
command.add(githubToken)
} else {
println("CHANGELOG_GITHUB_TOKEN not found, which limits the allowed amount of Github API calls")
}
exec {
commandLine(command)
standardOutput = System.out
errorOutput = System.err
}
val config = PropertiesConfiguration()
val fileHandler = FileHandler(config).apply {
file = rootProject.file("gradle.properties")
load()
}
val currentVersionName = config.getProperty("app.versionName")
config.setProperty("app.lastVersionName", currentVersionName)
fileHandler.save()
println("Updated app.lastVersionName to $currentVersionName")
}
}
afterEvaluate {
tasks.named("bundleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest"))
}
tasks.named("assembleRelease").configure {
dependsOn(tasks.named("testReleaseUnitTest"))
finalizedBy(tasks.named("generateChangelogs"))
}
}
dependencies {
val navVersion = "2.3.5"
val roomVersion = "2.6.1"
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
implementation("androidx.preference:preference-ktx:1.2.1")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
implementation("androidx.work:work-runtime:2.9.1")
implementation("androidx.biometric:biometric:1.1.0")
implementation("cat.ereza:customactivityoncrash:2.4.0")
implementation("com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0")
implementation("com.github.bumptech.glide:glide:4.15.1")
implementation("cn.Leaqi:SwipeDrawer:1.6")
implementation("com.github.skydoves:colorpickerview:2.3.0")
implementation("com.google.android.material:material:1.12.0")
implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("me.zhanghai.android.fastscroll:library:1.3.0")
implementation("net.lingala.zip4j:zip4j:2.11.5")
implementation("net.zetetic:android-database-sqlcipher:4.5.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jsoup:jsoup:1.18.1")
implementation("org.ocpsoft.prettytime:prettytime:4.0.6.Final")
implementation("org.simpleframework:simple-xml:2.7.1") {
exclude(group = "xpp3", module = "xpp3")
}
androidTestImplementation("androidx.room:room-testing:$roomVersion")
androidTestImplementation("androidx.work:work-testing:2.9.1")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.test:core-ktx:1.6.1")
testImplementation("androidx.test:core:1.6.1")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.24.2")
testImplementation("org.json:json:20180813")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("org.mockito:mockito-core:5.13.0")
testImplementation("org.robolectric:robolectric:4.13")
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@ -11,39 +11,16 @@
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
-dontobfuscate
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
-printmapping obfuscation/mapping.txt
# Uncomment this to preserve the line number information for
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
-renamesourcefileattribute SourceFile
-keep class ** extends androidx.navigation.Navigator
-keep class ** implements org.ocpsoft.prettytime.TimeUnit
# SQLCipher
-keep class net.sqlcipher.** { *; }
-keep class net.sqlcipher.database.** { *; }
# SimpleXML
-keepattributes Signature
-keepattributes *Annotation
-keep interface org.simpleframework.xml.core.Label {
public *;
}
-keep class * implements org.simpleframework.xml.core.Label {
public *;
}
-keep interface org.simpleframework.xml.core.Parameter {
public *;
}
-keep class * implements org.simpleframework.xml.core.Parameter {
public *;
}
-keep interface org.simpleframework.xml.core.Extractor {
public *;
}
-keep class * implements org.simpleframework.xml.core.Extractor {
public *;
}
-keep class * implements java.io.Serializable
-keep class net.sqlcipher.database.** { *; }

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
"identityHash": "a0ebadcc625f8b49bf549975d7288f10",
"entities": [
{
"tableName": "BaseNote",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
@ -97,12 +97,6 @@
"columnName": "audios",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reminders",
"columnName": "reminders",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
@ -152,7 +146,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a0ebadcc625f8b49bf549975d7288f10')"
]
}
}

View file

@ -1,158 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
"entities": [
{
"tableName": "BaseNote",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modifiedTimestamp",
"columnName": "modifiedTimestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "labels",
"columnName": "labels",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "spans",
"columnName": "spans",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "items",
"columnName": "items",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "images",
"columnName": "images",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "audios",
"columnName": "audios",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reminders",
"columnName": "reminders",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
"unique": false,
"columnNames": [
"id",
"folder",
"pinned",
"timestamp",
"labels"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
}
],
"foreignKeys": []
},
{
"tableName": "Label",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
"fields": [
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"value"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
]
}
}

View file

@ -1,158 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "3ac03ff6740f6a6bcb19de11c7b3d750",
"entities": [
{
"tableName": "BaseNote",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modifiedTimestamp",
"columnName": "modifiedTimestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "labels",
"columnName": "labels",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "spans",
"columnName": "spans",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "items",
"columnName": "items",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "images",
"columnName": "images",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "audios",
"columnName": "audios",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reminders",
"columnName": "reminders",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
"unique": false,
"columnNames": [
"id",
"folder",
"pinned",
"timestamp",
"labels"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
}
],
"foreignKeys": []
},
{
"tableName": "Label",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
"fields": [
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"value"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3ac03ff6740f6a6bcb19de11c7b3d750')"
]
}
}

View file

@ -1,164 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "042b20b5b4cfc8415e6cf6348196e869",
"entities": [
{
"tableName": "BaseNote",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `folder` TEXT NOT NULL, `color` TEXT NOT NULL, `title` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `modifiedTimestamp` INTEGER NOT NULL, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `files` TEXT NOT NULL, `audios` TEXT NOT NULL, `reminders` TEXT NOT NULL, `viewMode` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modifiedTimestamp",
"columnName": "modifiedTimestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "labels",
"columnName": "labels",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "spans",
"columnName": "spans",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "items",
"columnName": "items",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "images",
"columnName": "images",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "audios",
"columnName": "audios",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reminders",
"columnName": "reminders",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "viewMode",
"columnName": "viewMode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_BaseNote_id_folder_pinned_timestamp_labels",
"unique": false,
"columnNames": [
"id",
"folder",
"pinned",
"timestamp",
"labels"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_BaseNote_id_folder_pinned_timestamp_labels` ON `${TABLE_NAME}` (`id`, `folder`, `pinned`, `timestamp`, `labels`)"
}
],
"foreignKeys": []
},
{
"tableName": "Label",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`value` TEXT NOT NULL, PRIMARY KEY(`value`))",
"fields": [
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"value"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '042b20b5b4cfc8415e6cf6348196e869')"
]
}
}

View file

@ -6,15 +6,13 @@
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application
@ -43,7 +41,8 @@
<activity
android:name=".presentation.activity.main.MainActivity"
android:exported="true">
android:exported="true"
android:theme="@style/MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -68,38 +67,15 @@
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" android:mimeType="text/*" />
<data android:scheme="content" android:mimeType="text/*" />
<data android:scheme="file" android:mimeType="application/json" />
<data android:scheme="content" android:mimeType="application/json" />
<data android:scheme="file" android:mimeType="application/xml" />
<data android:scheme="content" android:mimeType="application/xml" />
</intent-filter>
</activity>
<activity android:name=".presentation.activity.note.ViewImageActivity" />
<activity android:name=".presentation.activity.note.SelectLabelsActivity" />
<activity android:name=".presentation.activity.note.reminders.RemindersActivity" />
<activity
android:name=".presentation.activity.note.RecordAudioActivity"
android:launchMode="singleTask" />
@ -116,21 +92,6 @@
</activity>
<activity
android:name=".presentation.activity.note.PickNoteActivity"
android:exported="false">
</activity>
<activity
android:name=".utils.ErrorActivity"
android:exported="true"
android:label="@string/unknown_error"
android:process=":error_activity">
<intent-filter>
<action android:name="cat.ereza.customactivityoncrash.ERROR" />
</intent-filter>
</activity>
<receiver
android:name=".presentation.widget.WidgetProvider"
android:exported="false"
@ -146,18 +107,15 @@
</receiver>
<receiver android:name=".presentation.activity.note.reminders.ReminderReceiver" android:enabled="true" android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service
android:name=".presentation.widget.WidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".data.AttachmentDeleteService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".utils.audio.AudioRecordService"
android:exported="false"
@ -165,8 +123,7 @@
<service
android:name=".utils.audio.AudioPlayService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
android:exported="false" />
</application>

View file

@ -1,83 +0,0 @@
package android.print
import android.content.ContentResolver
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.documentfile.provider.DocumentFile
import com.philkes.notallyx.utils.nameWithoutExtension
/**
* Needs to be in android.print package to access the package private methods of
* [PrintDocumentAdapter]
*/
fun Context.printPdf(file: DocumentFile, content: String, pdfPrintListener: PdfPrintListener) {
val webView = WebView(this)
webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
webView.webViewClient =
object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val adapter = webView.createPrintDocumentAdapter(file.nameWithoutExtension!!)
contentResolver.printPdf(file, adapter, pdfPrintListener)
}
}
}
private fun ContentResolver.printPdf(
file: DocumentFile,
adapter: PrintDocumentAdapter,
pdfPrintListener: PdfPrintListener,
) {
val onLayoutResult =
object : PrintDocumentAdapter.LayoutResultCallback() {
override fun onLayoutFailed(error: CharSequence?) {
pdfPrintListener.onFailure(error)
}
override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) {
this@printPdf.writeToFile(file, adapter, pdfPrintListener)
}
}
adapter.onLayout(null, createPrintAttributes(), null, onLayoutResult, null)
}
private fun ContentResolver.writeToFile(
file: DocumentFile,
adapter: PrintDocumentAdapter,
pdfPrintListener: PdfPrintListener,
) {
val onWriteResult =
object : PrintDocumentAdapter.WriteResultCallback() {
override fun onWriteFailed(error: CharSequence?) {
pdfPrintListener.onFailure(error)
}
override fun onWriteFinished(pages: Array<out PageRange>?) {
pdfPrintListener.onSuccess(file)
}
}
val pages = arrayOf(PageRange.ALL_PAGES)
val fileDescriptor = openFileDescriptor(file.uri, "rw")
adapter.onWrite(pages, fileDescriptor, null, onWriteResult)
}
private fun createPrintAttributes(): PrintAttributes {
return with(PrintAttributes.Builder()) {
setMediaSize(PrintAttributes.MediaSize.ISO_A4)
setMinMargins(PrintAttributes.Margins.NO_MARGINS)
setResolution(PrintAttributes.Resolution("Standard", "Standard", 100, 100))
build()
}
}
interface PdfPrintListener {
fun onSuccess(file: DocumentFile)
fun onFailure(message: CharSequence?)
}

View file

@ -0,0 +1,79 @@
package android.print
import android.content.Context
import android.os.ParcelFileDescriptor
import android.webkit.WebView
import android.webkit.WebViewClient
import java.io.File
/**
* This class needs to be in android.print package to access the package private methods of
* [PrintDocumentAdapter]
*/
object PostPDFGenerator {
fun create(file: File, content: String, context: Context, onResult: OnResult) {
val webView = WebView(context)
webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
webView.webViewClient =
object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val adapter = webView.createPrintDocumentAdapter(file.nameWithoutExtension)
print(file, adapter, onResult)
}
}
}
private fun print(file: File, adapter: PrintDocumentAdapter, onResult: OnResult) {
val onLayoutResult =
object : PrintDocumentAdapter.LayoutResultCallback() {
override fun onLayoutFailed(error: CharSequence?) {
onResult.onFailure(error)
}
override fun onLayoutFinished(info: PrintDocumentInfo?, changed: Boolean) {
writeToFile(file, adapter, onResult)
}
}
adapter.onLayout(null, getPrintAttributes(), null, onLayoutResult, null)
}
private fun writeToFile(file: File, adapter: PrintDocumentAdapter, onResult: OnResult) {
val onWriteResult =
object : PrintDocumentAdapter.WriteResultCallback() {
override fun onWriteFailed(error: CharSequence?) {
onResult.onFailure(error)
}
override fun onWriteFinished(pages: Array<out PageRange>?) {
onResult.onSuccess(file)
}
}
val pages = arrayOf(PageRange.ALL_PAGES)
if (!file.exists()) {
file.createNewFile()
}
val fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE)
adapter.onWrite(pages, fileDescriptor, null, onWriteResult)
}
private fun getPrintAttributes(): PrintAttributes {
val builder = PrintAttributes.Builder()
builder.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
builder.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
builder.setResolution(PrintAttributes.Resolution("Standard", "Standard", 100, 100))
return builder.build()
}
interface OnResult {
fun onSuccess(file: File)
fun onFailure(message: CharSequence?)
}
}

View file

@ -1,118 +0,0 @@
package androidx.recyclerview.widget
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.core.widget.NestedScrollView
import kotlin.math.abs
class NestedScrollViewItemTouchHelper(
callback: Callback,
private val scrollView: NestedScrollView,
) : ItemTouchHelper(callback) {
private var selectedStartY: Int = -1
private var selectedStartScrollY: Float = -1f
private var selectedView: View? = null
private var dragScrollStartTimeInMs: Long = 0
private var lastmDy = 0f
private var lastScrollY = 0
private var tmpRect: Rect? = null
override fun select(selected: RecyclerView.ViewHolder?, actionState: Int) {
super.select(selected, actionState)
if (selected != null) {
selectedView = selected.itemView
selectedStartY = selected.itemView.top
selectedStartScrollY = scrollView!!.scrollY.toFloat()
}
}
/**
* Scrolls [scrollView] when an item in [mRecyclerView] is dragged to the top or bottom of the
* [scrollView].
*
* Inspired by
* [https://stackoverflow.com/a/70699988/9748566](https://stackoverflow.com/a/70699988/9748566)
*/
override fun scrollIfNecessary(): Boolean {
if (mSelected == null) {
dragScrollStartTimeInMs = Long.MIN_VALUE
return false
}
val now = System.currentTimeMillis()
val scrollDuration =
if (dragScrollStartTimeInMs == Long.MIN_VALUE) 0 else now - dragScrollStartTimeInMs
val lm = mRecyclerView.layoutManager
if (tmpRect == null) {
tmpRect = Rect()
}
var scrollY = 0
val currentScrollY = scrollView.scrollY
// We need to use the height of NestedScrollView, not RecyclerView's!
val actualShowingHeight =
scrollView.height - mRecyclerView.top - mRecyclerView.paddingBottom
lm!!.calculateItemDecorationsForChild(mSelected.itemView, tmpRect!!)
if (lm.canScrollVertically()) {
// Keep scrolling if the user didnt change the drag direction
if (lastScrollY != 0 && abs(lastmDy) >= abs(mDy)) {
scrollY = lastScrollY
} else {
// The true current Y of the item in NestedScrollView, not in RecyclerView!
val curY = (selectedStartY + mDy - currentScrollY).toInt()
// The true mDy should plus the initial scrollY and minus current scrollY of
// NestedScrollView
val checkDy = (mDy + selectedStartScrollY - currentScrollY).toInt()
val topDiff = curY - tmpRect!!.top - mRecyclerView.paddingTop
if (checkDy < 0 && topDiff < 0) { // User is draging the item out of the top edge.
scrollY = topDiff
} else if (checkDy > 0) { // User is draging the item out of the bottom edge.
val bottomDiff = (curY + mSelected.itemView.height - actualShowingHeight) + 10
if (bottomDiff >= 0) {
scrollY = bottomDiff
}
} else {
scrollY = 0
}
}
}
lastScrollY = scrollY
lastmDy = mDy
if (scrollY != 0) {
scrollY =
mCallback.interpolateOutOfBoundsScroll(
mRecyclerView,
mSelected.itemView.height,
scrollY,
actualShowingHeight,
scrollDuration,
)
}
if (scrollY != 0) {
val maxScrollY = scrollView.childrenHeightsSum - scrollView.height
// Check if we can scroll further before applying the scroll
if (
(scrollY < 0 && scrollView.scrollY > 0) ||
(scrollY > 0 && scrollView.scrollY < maxScrollY)
) {
if (dragScrollStartTimeInMs == Long.MIN_VALUE) {
dragScrollStartTimeInMs = now
}
scrollView.scrollBy(0, scrollY)
// Update the dragged item position as well
selectedView?.translationY = selectedView!!.translationY + scrollY
return true
}
}
dragScrollStartTimeInMs = Long.MIN_VALUE
lastScrollY = 0
lastmDy = 0f
return false
}
private val ViewGroup.childrenHeightsSum
get() = children.map { it.measuredHeight }.sum()
}

View file

@ -1,191 +1,51 @@
package com.philkes.notallyx
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.Observer
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.google.android.material.color.DynamicColors
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.backup.AUTO_BACKUP_WORK_NAME
import com.philkes.notallyx.utils.backup.autoBackupOnSave
import com.philkes.notallyx.utils.backup.cancelAutoBackup
import com.philkes.notallyx.utils.backup.containsNonCancelled
import com.philkes.notallyx.utils.backup.deleteModifiedNoteBackup
import com.philkes.notallyx.utils.backup.isEqualTo
import com.philkes.notallyx.utils.backup.modifiedNoteBackupExists
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
import com.philkes.notallyx.presentation.view.misc.Theme
import com.philkes.notallyx.utils.backup.scheduleAutoBackup
import com.philkes.notallyx.utils.backup.updateAutoBackup
import com.philkes.notallyx.utils.observeOnce
import com.philkes.notallyx.utils.security.UnlockReceiver
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NotallyXApplication : Application(), Application.ActivityLifecycleCallbacks {
class NotallyXApplication : Application() {
private lateinit var biometricLockObserver: Observer<BiometricLock>
private lateinit var preferences: NotallyXPreferences
private lateinit var biometricLockObserver: Observer<String>
private lateinit var preferences: Preferences
private var unlockReceiver: UnlockReceiver? = null
val locked = NotNullLiveData(true)
var isLocked = true
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(this)
if (isTestRunner()) return
preferences = NotallyXPreferences.getInstance(this)
if (preferences.useDynamicColors.value) {
if (DynamicColors.isDynamicColorAvailable()) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
} else {
setTheme(R.style.AppTheme)
}
preferences.theme.observeForeverWithPrevious { (oldTheme, theme) ->
preferences = Preferences.getInstance(this)
preferences.theme.observeForever { theme ->
when (theme) {
Theme.DARK ->
Theme.dark ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
Theme.LIGHT ->
Theme.light ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
Theme.FOLLOW_SYSTEM ->
Theme.followSystem ->
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
)
}
if (oldTheme != null) {
WidgetProvider.updateWidgets(this, locked = locked.value)
}
}
preferences.backupsFolder.observeForeverWithPrevious { (backupFolderBefore, backupFolder) ->
checkUpdatePeriodicBackup(
backupFolderBefore,
backupFolder,
preferences.periodicBackups.value.periodInDays.toLong(),
)
}
preferences.periodicBackups.observeForever { value ->
val backupFolder = preferences.backupsFolder.value
checkUpdatePeriodicBackup(backupFolder, backupFolder, value.periodInDays.toLong())
}
scheduleAutoBackup(preferences.autoBackupPeriodDays.value.toLong(), this)
val filter = IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) }
biometricLockObserver = Observer { biometricLock ->
if (biometricLock == BiometricLock.ENABLED) {
biometricLockObserver = Observer {
if (it == enabled) {
unlockReceiver = UnlockReceiver(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(unlockReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(unlockReceiver, filter)
}
} else {
unlockReceiver?.let { unregisterReceiver(it) }
if (locked.value) {
locked.postValue(false)
}
registerReceiver(unlockReceiver, filter)
} else if (unlockReceiver != null) {
unregisterReceiver(unlockReceiver)
}
}
preferences.biometricLock.observeForever(biometricLockObserver)
locked.observeForever { isLocked -> WidgetProvider.updateWidgets(this, locked = isLocked) }
preferences.backupPassword.observeForeverWithPrevious {
(previousBackupPassword, backupPassword) ->
if (preferences.backupOnSave.value) {
val backupPath = preferences.backupsFolder.value
if (backupPath != EMPTY_PATH) {
if (
!modifiedNoteBackupExists(backupPath) ||
(previousBackupPassword != null &&
previousBackupPassword != backupPassword)
) {
deleteModifiedNoteBackup(backupPath)
MainScope().launch {
withContext(Dispatchers.IO) {
autoBackupOnSave(
backupPath,
savedNote = null,
password = backupPassword,
)
}
}
}
}
}
}
}
private fun checkUpdatePeriodicBackup(
backupFolderBefore: String?,
backupFolder: String,
periodInDays: Long,
) {
val workManager = getWorkManagerSafe() ?: return
workManager.getWorkInfosForUniqueWorkLiveData(AUTO_BACKUP_WORK_NAME).observeOnce { workInfos
->
if (backupFolder == EMPTY_PATH || periodInDays < 1) {
if (workInfos?.containsNonCancelled() == true) {
workManager.cancelAutoBackup()
}
} else if (
workInfos.isNullOrEmpty() ||
workInfos.all { it.state == WorkInfo.State.CANCELLED } ||
(backupFolderBefore != null && backupFolderBefore != backupFolder)
) {
workManager.scheduleAutoBackup(this, periodInDays)
} else if (
workInfos.first().periodicityInfo?.isEqualTo(periodInDays, TimeUnit.DAYS) == false
) {
workManager.updateAutoBackup(workInfos, periodInDays)
}
}
}
private fun getWorkManagerSafe(): WorkManager? {
return try {
WorkManager.getInstance(this)
} catch (e: Exception) {
// TODO: Happens when ErrorActivity is launched
null
}
}
companion object {
private fun isTestRunner(): Boolean {
return Build.FINGERPRINT.equals("robolectric", ignoreCase = true)
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activity.setEnabledSecureFlag(preferences.secureFlag.value)
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}

View file

@ -0,0 +1,203 @@
package com.philkes.notallyx
import android.app.Application
import android.os.Build
import android.preference.PreferenceManager
import com.philkes.notallyx.presentation.view.misc.AutoBackup
import com.philkes.notallyx.presentation.view.misc.AutoBackupMax
import com.philkes.notallyx.presentation.view.misc.AutoBackupPeriodDays
import com.philkes.notallyx.presentation.view.misc.BetterLiveData
import com.philkes.notallyx.presentation.view.misc.BiometricLock
import com.philkes.notallyx.presentation.view.misc.DateFormat
import com.philkes.notallyx.presentation.view.misc.ListInfo
import com.philkes.notallyx.presentation.view.misc.ListItemSorting
import com.philkes.notallyx.presentation.view.misc.MaxItems
import com.philkes.notallyx.presentation.view.misc.MaxLines
import com.philkes.notallyx.presentation.view.misc.MaxTitle
import com.philkes.notallyx.presentation.view.misc.NotesSorting
import com.philkes.notallyx.presentation.view.misc.SeekbarInfo
import com.philkes.notallyx.presentation.view.misc.SortDirection
import com.philkes.notallyx.presentation.view.misc.TextInfo
import com.philkes.notallyx.presentation.view.misc.TextSize
import com.philkes.notallyx.presentation.view.misc.Theme
import com.philkes.notallyx.presentation.view.misc.View
import com.philkes.notallyx.utils.toPreservedByteArray
import com.philkes.notallyx.utils.toPreservedString
import java.security.SecureRandom
import javax.crypto.Cipher
private const val DATABASE_ENCRYPTION_KEY = "database_encryption_key"
private const val ENCRYPTION_IV = "encryption_iv"
/**
* Custom implementation of androidx.preference library Way faster, simpler and smaller, logic of
* storing preferences has been decoupled from their UI. It is backed by SharedPreferences but it
* should be trivial to shift to another source if needed.
*/
class Preferences private constructor(app: Application) {
private val preferences = PreferenceManager.getDefaultSharedPreferences(app)
private val editor = preferences.edit()
// Main thread (unfortunately)
val view = BetterLiveData(getListPref(View))
val theme = BetterLiveData(getListPref(Theme))
val dateFormat = BetterLiveData(getListPref(DateFormat))
val notesSorting = BetterLiveData(getNotesSorting(NotesSorting))
val textSize = BetterLiveData(getListPref(TextSize))
val listItemSorting = BetterLiveData(getListPref(ListItemSorting))
var maxItems = getSeekbarPref(MaxItems)
var maxLines = getSeekbarPref(MaxLines)
var maxTitle = getSeekbarPref(MaxTitle)
val autoBackupPath = BetterLiveData(getTextPref(AutoBackup))
var autoBackupPeriodDays = BetterLiveData(getSeekbarPref(AutoBackupPeriodDays))
var autoBackupMax = getSeekbarPref(AutoBackupMax)
val biometricLock = BetterLiveData(getListPref(BiometricLock))
var iv: ByteArray?
get() = preferences.getString(ENCRYPTION_IV, null)?.toPreservedByteArray
set(value) {
editor.putString(ENCRYPTION_IV, value?.toPreservedString)
editor.commit()
}
fun getDatabasePassphrase(): ByteArray {
val string = preferences.getString(DATABASE_ENCRYPTION_KEY, "")!!
return string.toPreservedByteArray
}
fun generatePassphrase(cipher: Cipher): ByteArray {
val random =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SecureRandom.getInstanceStrong()
} else {
SecureRandom()
}
val result = ByteArray(64)
random.nextBytes(result)
// filter out zero byte values, as SQLCipher does not like them
while (result.contains(0)) {
random.nextBytes(result)
}
val encryptedPassphrase = cipher.doFinal(result)
editor.putString(DATABASE_ENCRYPTION_KEY, encryptedPassphrase.toPreservedString)
editor.commit()
return result
}
private fun getListPref(info: ListInfo) =
requireNotNull(preferences.getString(info.key, info.defaultValue))
private fun getNotesSorting(info: NotesSorting): Pair<String, SortDirection> {
val sortBy = requireNotNull(preferences.getString(info.key, info.defaultValue))
val sortDirection =
requireNotNull(preferences.getString(info.directionKey, info.defaultValueDirection))
return Pair(sortBy, SortDirection.valueOf(sortDirection))
}
private fun getTextPref(info: TextInfo) =
requireNotNull(preferences.getString(info.key, info.defaultValue))
private fun getSeekbarPref(info: SeekbarInfo) =
requireNotNull(preferences.getInt(info.key, info.defaultValue))
fun getWidgetData(id: Int) = preferences.getLong("widget:$id", 0)
fun deleteWidget(id: Int) {
editor.remove("widget:$id")
editor.commit()
}
fun updateWidget(id: Int, noteId: Long) {
editor.putLong("widget:$id", noteId)
editor.commit()
}
fun getUpdatableWidgets(noteIds: LongArray): List<Pair<Int, Long>> {
val updatableWidgets = ArrayList<Pair<Int, Long>>()
val pairs = preferences.all
pairs.keys.forEach { key ->
val token = "widget:"
if (key.startsWith(token)) {
val end = key.substringAfter(token)
val id = end.toIntOrNull()
if (id != null) {
val value = pairs[key] as? Long
if (value != null) {
if (noteIds.contains(value)) {
updatableWidgets.add(Pair(id, value))
}
}
}
}
}
return updatableWidgets
}
fun savePreference(info: SeekbarInfo, value: Int) {
editor.putInt(info.key, value)
editor.commit()
when (info) {
MaxItems -> maxItems = getSeekbarPref(MaxItems)
MaxLines -> maxLines = getSeekbarPref(MaxLines)
MaxTitle -> maxTitle = getSeekbarPref(MaxTitle)
AutoBackupMax -> autoBackupMax = getSeekbarPref(AutoBackupMax)
AutoBackupPeriodDays ->
autoBackupPeriodDays.postValue(getSeekbarPref(AutoBackupPeriodDays))
}
}
fun savePreference(info: NotesSorting, sortBy: String, sortDirection: SortDirection) {
editor.putString(info.key, sortBy)
editor.putString(info.directionKey, sortDirection.name)
editor.commit()
notesSorting.postValue(getNotesSorting(info))
}
fun savePreference(info: ListInfo, value: String) {
editor.putString(info.key, value)
editor.commit()
when (info) {
View -> view.postValue(getListPref(info))
Theme -> theme.postValue(getListPref(info))
DateFormat -> dateFormat.postValue(getListPref(info))
TextSize -> textSize.postValue(getListPref(info))
ListItemSorting -> listItemSorting.postValue(getListPref(info))
BiometricLock -> biometricLock.postValue(getListPref(info))
else -> return
}
}
fun savePreference(info: TextInfo, value: String) {
editor.putString(info.key, value)
editor.commit()
when (info) {
AutoBackup -> autoBackupPath.postValue(getTextPref(info))
}
}
fun showDateCreated(): Boolean {
return dateFormat.value != DateFormat.none
}
companion object {
@Volatile private var instance: Preferences? = null
fun getInstance(app: Application): Preferences {
return instance
?: synchronized(this) {
val instance = Preferences(app)
Companion.instance = instance
return instance
}
}
}
}

View file

@ -0,0 +1,124 @@
package com.philkes.notallyx.data
import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.content.ContextCompat
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Attachment
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.utils.IO
import com.philkes.notallyx.utils.isImage
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AttachmentDeleteService : Service() {
private val scope = MainScope()
private val channel = Channel<ArrayList<Attachment>>()
override fun onCreate() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val builder =
Notification.Builder(application).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = "com.philkes.fileUpdates"
val channel =
NotificationChannel(
channelId,
"Backups and Files",
NotificationManager.IMPORTANCE_DEFAULT,
)
manager.createNotificationChannel(channel)
setChannelId(channelId)
}
setContentTitle(getString(R.string.deleting_images))
setSmallIcon(R.drawable.notification_delete)
setProgress(0, 0, true)
setOnlyAlertOnce(true)
/*
Prevent user from dismissing notification in Android 13 (33) and above
https://developer.android.com/guide/components/foreground-services#user-dismiss-notification
*/
setOngoing(true)
}
/*
On Android 12 (31) and above, the system waits 10 seconds before showing the notification.
https://developer.android.com/guide/components/foreground-services#notification-immediate
*/
startForeground(1, builder.build())
scope.launch {
withContext(Dispatchers.IO) {
val imageRoot = IO.getExternalImagesDirectory(application)
val audioRoot = IO.getExternalAudioDirectory(application)
val fileRoot = IO.getExternalFilesDirectory(application)
do {
val attachments = channel.receive()
attachments.forEachIndexed { index, attachment ->
val file =
when (attachment) {
is Audio ->
if (audioRoot != null) File(audioRoot, attachment.name)
else null
is FileAttachment -> {
val root = if (attachment.isImage) imageRoot else fileRoot
if (root != null) File(root, attachment.localName) else null
}
}
if (file != null && file.exists()) {
file.delete()
}
builder.setContentText(
getString(R.string.count, index + 1, attachments.size)
)
builder.setProgress(attachments.size, index + 1, false)
manager.notify(1, builder.build())
}
} while (!channel.isEmpty)
channel.close()
stopSelf()
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch {
val list =
requireNotNull(intent).getParcelableArrayListExtra<Attachment>(EXTRA_ATTACHMENTS)
withContext(Dispatchers.IO) { channel.send(requireNotNull(list)) }
}
return START_NOT_STICKY
}
override fun onDestroy() {
scope.cancel()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
private const val EXTRA_ATTACHMENTS = "com.philkes.notallyx.EXTRA_ATTACHMENTS"
fun start(app: Application, list: ArrayList<out Attachment>) {
val intent = Intent(app, AttachmentDeleteService::class.java)
intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, list)
ContextCompat.startForegroundService(app, intent)
}
}
}

View file

@ -1,9 +1,7 @@
package com.philkes.notallyx.data
import android.content.Context
import android.content.ContextWrapper
import android.app.Application
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
import androidx.room.Database
import androidx.room.Room
@ -12,28 +10,21 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteDatabase
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.data.dao.BaseNoteDao
import com.philkes.notallyx.data.dao.CommonDao
import com.philkes.notallyx.data.dao.LabelDao
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.toColorString
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.observeForeverSkipFirst
import com.philkes.notallyx.utils.getExternalMediaDirectory
import com.philkes.notallyx.utils.security.SQLCipherUtils
import com.philkes.notallyx.presentation.view.misc.BetterLiveData
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
import com.philkes.notallyx.utils.observeForeverSkipFirst
import com.philkes.notallyx.utils.security.getInitializedCipherForDecryption
import java.io.File
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SupportFactory
@TypeConverters(Converters::class)
@Database(entities = [BaseNote::class, Label::class], version = 9)
@Database(entities = [BaseNote::class, Label::class], version = 6)
abstract class NotallyDatabase : RoomDatabase() {
abstract fun getLabelDao(): LabelDao
@ -46,162 +37,58 @@ abstract class NotallyDatabase : RoomDatabase() {
getBaseNoteDao().query(SimpleSQLiteQuery("pragma wal_checkpoint(FULL)"))
}
private var biometricLockObserver: Observer<BiometricLock>? = null
private var dataInPublicFolderObserver: Observer<Boolean>? = null
private var observer: Observer<String>? = null
companion object {
const val DATABASE_NAME = "NotallyDatabase"
const val DatabaseName = "NotallyDatabase"
@Volatile private var instance: NotNullLiveData<NotallyDatabase>? = null
@Volatile private var instance: BetterLiveData<NotallyDatabase>? = null
fun getCurrentDatabaseFile(context: ContextWrapper): File {
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context)
} else {
getInternalDatabaseFile(context)
}
}
fun getExternalDatabaseFile(context: ContextWrapper): File {
return File(context.getExternalMediaDirectory(), DATABASE_NAME)
}
fun getExternalDatabaseFiles(context: ContextWrapper): List<File> {
return listOf(
File(context.getExternalMediaDirectory(), DATABASE_NAME),
File(context.getExternalMediaDirectory(), "$DATABASE_NAME-shm"),
File(context.getExternalMediaDirectory(), "$DATABASE_NAME-wal"),
)
}
fun getInternalDatabaseFile(context: Context): File {
return context.getDatabasePath(DATABASE_NAME)
}
fun getInternalDatabaseFiles(context: ContextWrapper): List<File> {
val directory = context.getDatabasePath(DATABASE_NAME).parentFile
return listOf(
File(directory, DATABASE_NAME),
File(directory, "$DATABASE_NAME-shm"),
File(directory, "$DATABASE_NAME-wal"),
)
}
private fun getCurrentDatabaseName(context: ContextWrapper): String {
return if (NotallyXPreferences.getInstance(context).dataInPublicFolder.value) {
getExternalDatabaseFile(context).absolutePath
} else {
DATABASE_NAME
}
}
fun getDatabase(
context: ContextWrapper,
observePreferences: Boolean = true,
): NotNullLiveData<NotallyDatabase> {
fun getDatabase(app: Application): BetterLiveData<NotallyDatabase> {
return instance
?: synchronized(this) {
val preferences = NotallyXPreferences.getInstance(context)
val preferences = Preferences.getInstance(app)
this.instance =
NotNullLiveData(createInstance(context, preferences, observePreferences))
BetterLiveData(
createInstance(app, preferences, preferences.biometricLock.value)
)
return this.instance!!
}
}
fun getFreshDatabase(context: ContextWrapper): NotallyDatabase {
return createInstance(context, NotallyXPreferences.getInstance(context), false)
}
private fun createInstance(
context: ContextWrapper,
preferences: NotallyXPreferences,
observePreferences: Boolean,
app: Application,
preferences: Preferences,
biometrickLock: String,
): NotallyDatabase {
val instanceBuilder =
Room.databaseBuilder(
context,
NotallyDatabase::class.java,
getCurrentDatabaseName(context),
)
.addMigrations(
Migration2,
Migration3,
Migration4,
Migration5,
Migration6,
Migration7,
Migration8,
Migration9,
)
Room.databaseBuilder(app, NotallyDatabase::class.java, DatabaseName)
.addMigrations(Migration2, Migration3, Migration4, Migration5, Migration6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
SQLiteDatabase.loadLibs(context)
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.State.ENCRYPTED
) {
initializeDecryption(preferences, instanceBuilder)
} else {
preferences.biometricLock.save(BiometricLock.DISABLED)
}
} else {
if (
SQLCipherUtils.getDatabaseState(getCurrentDatabaseFile(context)) ==
SQLCipherUtils.State.ENCRYPTED
) {
preferences.biometricLock.save(BiometricLock.ENABLED)
initializeDecryption(preferences, instanceBuilder)
}
if (biometrickLock == enabled) {
val initializationVector = preferences.iv!!
val cipher = getInitializedCipherForDecryption(iv = initializationVector)
val encryptedPassphrase = preferences.getDatabasePassphrase()
val passphrase = cipher.doFinal(encryptedPassphrase)
val factory = SupportFactory(passphrase)
instanceBuilder.openHelperFactory(factory)
}
val instance = instanceBuilder.build()
if (observePreferences) {
instance.biometricLockObserver = Observer {
NotallyDatabase.instance?.value?.biometricLockObserver?.let {
preferences.biometricLock.removeObserver(it)
}
val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance)
preferences.biometricLock.observeForeverSkipFirst(
newInstance.biometricLockObserver!!
)
instance.observer = Observer { newBiometrickLock ->
NotallyDatabase.instance?.value?.observer?.let {
preferences.biometricLock.removeObserver(it)
}
preferences.biometricLock.observeForeverSkipFirst(
instance.biometricLockObserver!!
)
instance.dataInPublicFolderObserver = Observer {
NotallyDatabase.instance?.value?.dataInPublicFolderObserver?.let {
preferences.dataInPublicFolder.removeObserver(it)
}
val newInstance = createInstance(context, preferences, true)
NotallyDatabase.instance?.postValue(newInstance)
preferences.dataInPublicFolder.observeForeverSkipFirst(
newInstance.dataInPublicFolderObserver!!
)
}
preferences.dataInPublicFolder.observeForeverSkipFirst(
instance.dataInPublicFolderObserver!!
)
val newInstance = createInstance(app, preferences, newBiometrickLock)
NotallyDatabase.instance?.postValue(newInstance)
preferences.biometricLock.observeForeverSkipFirst(newInstance.observer!!)
}
preferences.biometricLock.observeForeverSkipFirst(instance.observer!!)
return instance
}
return instanceBuilder.build()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun initializeDecryption(
preferences: NotallyXPreferences,
instanceBuilder: Builder<NotallyDatabase>,
) {
val initializationVector = preferences.iv.value!!
val cipher = getInitializedCipherForDecryption(iv = initializationVector)
val encryptedPassphrase = preferences.databaseEncryptionKey.value
val passphrase = cipher.doFinal(encryptedPassphrase)
val factory = SupportFactory(passphrase)
instanceBuilder.openHelperFactory(factory)
}
object Migration2 : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
@ -240,37 +127,5 @@ abstract class NotallyDatabase : RoomDatabase() {
)
}
}
object Migration7 : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `BaseNote` ADD COLUMN `reminders` TEXT NOT NULL DEFAULT `[]`"
)
}
}
object Migration8 : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
val cursor = db.query("SELECT id, color FROM BaseNote")
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
val colorString = cursor.getString(cursor.getColumnIndexOrThrow("color"))
val color = Color.valueOfOrDefault(colorString)
val hexColor = color.toColorString()
db.execSQL("UPDATE BaseNote SET color = ? WHERE id = ?", arrayOf(hexColor, id))
}
cursor.close()
}
}
object Migration9 : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `BaseNote` ADD COLUMN `viewMode` TEXT NOT NULL DEFAULT '${NoteViewMode.EDIT.name}'"
)
}
}
}
}

View file

@ -11,21 +11,11 @@ import androidx.room.Update
import androidx.sqlite.db.SupportSQLiteQuery
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.LabelsInBaseNote
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.Type
data class NoteIdReminder(val id: Long, val reminders: List<Reminder>)
data class NoteReminder(
val id: Long,
val title: String,
val type: Type,
val reminders: List<Reminder>,
)
@Dao
interface BaseNoteDao {
@ -38,8 +28,6 @@ interface BaseNoteDao {
@Update(entity = BaseNote::class) suspend fun update(labelsInBaseNotes: List<LabelsInBaseNote>)
@Query("SELECT COUNT(*) FROM BaseNote") fun count(): Int
@Query("DELETE FROM BaseNote WHERE id = :id") suspend fun delete(id: Long)
@Query("DELETE FROM BaseNote WHERE id IN (:ids)") suspend fun delete(ids: LongArray)
@ -52,13 +40,7 @@ interface BaseNoteDao {
@Query("SELECT * FROM BaseNote WHERE folder = 'NOTES' ORDER BY pinned DESC, timestamp DESC")
suspend fun getAllNotes(): List<BaseNote>
@Query("SELECT * FROM BaseNote") fun getAllAsync(): LiveData<List<BaseNote>>
@Query("SELECT * FROM BaseNote") fun getAll(): List<BaseNote>
@Query("SELECT * FROM BaseNote WHERE id IN (:ids)") fun getByIds(ids: LongArray): List<BaseNote>
@Query("SELECT B.id FROM BaseNote B") fun getAllIds(): List<Long>
@Query("SELECT * FROM BaseNote") fun getAll(): LiveData<List<BaseNote>>
@Query("SELECT * FROM BaseNote WHERE id = :id") fun get(id: Long): BaseNote?
@ -70,16 +52,6 @@ interface BaseNoteDao {
@Query("SELECT audios FROM BaseNote") fun getAllAudios(): List<String>
@Query("SELECT id, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'")
suspend fun getAllReminders(): List<NoteIdReminder>
@Query("SELECT color FROM BaseNote WHERE id = :id ") fun getColorOfNote(id: Long): String?
@Query(
"SELECT id, title, type, reminders FROM BaseNote WHERE reminders IS NOT NULL AND reminders != '[]'"
)
fun getAllRemindersAsync(): LiveData<List<NoteReminder>>
@Query("SELECT id FROM BaseNote WHERE folder = 'DELETED'")
suspend fun getDeletedNoteIds(): LongArray
@ -95,15 +67,8 @@ interface BaseNoteDao {
@Query("UPDATE BaseNote SET folder = :folder WHERE id IN (:ids)")
suspend fun move(ids: LongArray, folder: Folder)
@Query("SELECT DISTINCT color FROM BaseNote") fun getAllColorsAsync(): LiveData<List<String>>
@Query("SELECT DISTINCT color FROM BaseNote") suspend fun getAllColors(): List<String>
@Query("UPDATE BaseNote SET color = :color WHERE id IN (:ids)")
suspend fun updateColor(ids: LongArray, color: String)
@Query("UPDATE BaseNote SET color = :newColor WHERE color = :oldColor")
suspend fun updateColor(oldColor: String, newColor: String)
suspend fun updateColor(ids: LongArray, color: Color)
@Query("UPDATE BaseNote SET pinned = :pinned WHERE id IN (:ids)")
suspend fun updatePinned(ids: LongArray, pinned: Boolean)
@ -111,9 +76,6 @@ interface BaseNoteDao {
@Query("UPDATE BaseNote SET labels = :labels WHERE id = :id")
suspend fun updateLabels(id: Long, labels: List<String>)
@Query("UPDATE BaseNote SET labels = :labels WHERE id IN (:ids)")
suspend fun updateLabels(ids: LongArray, labels: List<String>)
@Query("UPDATE BaseNote SET items = :items WHERE id = :id")
suspend fun updateItems(id: Long, items: List<ListItem>)
@ -126,9 +88,6 @@ interface BaseNoteDao {
@Query("UPDATE BaseNote SET audios = :audios WHERE id = :id")
suspend fun updateAudios(id: Long, audios: List<Audio>)
@Query("UPDATE BaseNote SET reminders = :reminders WHERE id = :id")
suspend fun updateReminders(id: Long, reminders: List<Reminder>)
/**
* Both id and position can be invalid.
*
@ -163,19 +122,14 @@ interface BaseNoteDao {
* directly on the LiveData to filter the results accordingly.
*/
fun getBaseNotesByLabel(label: String): LiveData<List<BaseNote>> {
val result = getBaseNotesByLabel(label, setOf(Folder.NOTES, Folder.ARCHIVED))
val result = getBaseNotesByLabel(label, Folder.NOTES)
return result.map { list -> list.filter { baseNote -> baseNote.labels.contains(label) } }
}
@Query(
"SELECT * FROM BaseNote WHERE folder IN (:folders) AND labels LIKE '%' || :label || '%' ORDER BY folder DESC, pinned DESC, timestamp DESC"
"SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByLabel(label: String, folders: Collection<Folder>): LiveData<List<BaseNote>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesWithoutLabel(folder: Folder): LiveData<List<BaseNote>>
fun getBaseNotesByLabel(label: String, folder: Folder): LiveData<List<BaseNote>>
suspend fun getListOfBaseNotesByLabel(label: String): List<BaseNote> {
val result = getListOfBaseNotesByLabelImpl(label)
@ -185,42 +139,16 @@ interface BaseNoteDao {
@Query("SELECT * FROM BaseNote WHERE labels LIKE '%' || :label || '%'")
suspend fun getListOfBaseNotesByLabelImpl(label: String): List<BaseNote>
fun getBaseNotesByKeyword(
keyword: String,
folder: Folder,
label: String?,
): LiveData<List<BaseNote>> {
val result =
when (label) {
null -> getBaseNotesByKeywordUnlabeledImpl(keyword, folder)
"" -> getBaseNotesByKeywordImpl(keyword, folder)
else -> getBaseNotesByKeywordImpl(keyword, folder, label)
}
fun getBaseNotesByKeyword(keyword: String, folder: Folder): LiveData<List<BaseNote>> {
val result = getBaseNotesByKeywordImpl(keyword, folder)
return result.map { list -> list.filter { baseNote -> matchesKeyword(baseNote, keyword) } }
}
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels LIKE '%' || :label || '%' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordImpl(
keyword: String,
folder: Folder,
label: String,
): LiveData<List<BaseNote>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%' OR labels LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordImpl(keyword: String, folder: Folder): LiveData<List<BaseNote>>
@Query(
"SELECT * FROM BaseNote WHERE folder = :folder AND labels == '[]' AND (title LIKE '%' || :keyword || '%' OR body LIKE '%' || :keyword || '%' OR items LIKE '%' || :keyword || '%') ORDER BY pinned DESC, timestamp DESC"
)
fun getBaseNotesByKeywordUnlabeledImpl(
keyword: String,
folder: Folder,
): LiveData<List<BaseNote>>
private fun matchesKeyword(baseNote: BaseNote, keyword: String): Boolean {
if (baseNote.title.contains(keyword, true)) {
return true

View file

@ -16,15 +16,10 @@ interface LabelDao {
@Query("DELETE FROM Label WHERE value = :value") suspend fun delete(value: String)
@Query("DELETE FROM Label") suspend fun deleteAll()
@Query("UPDATE Label SET value = :newValue WHERE value = :oldValue")
suspend fun update(oldValue: String, newValue: String)
@Query("SELECT value FROM Label ORDER BY value") fun getAll(): LiveData<List<String>>
@Query("SELECT value FROM Label ORDER BY value") suspend fun getArrayOfAll(): Array<String>
@Query("SELECT EXISTS(SELECT 1 FROM Label WHERE value = :value)")
suspend fun exists(value: String): Boolean
}

View file

@ -1,23 +0,0 @@
package com.philkes.notallyx.data.imports
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.data.model.BaseNote
import java.io.File
interface ExternalImporter {
/**
* Parses [BaseNote]s from [source] and copies attached files/images/audios to [destination]
*
* @return List of [BaseNote]s to import + folder containing attached files (if no attached
* files possible, return null).
*/
fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>? = null,
): Pair<List<BaseNote>, File?>
}

View file

@ -1,213 +0,0 @@
package com.philkes.notallyx.data.imports
import androidx.core.util.PatternsCompat
import com.philkes.notallyx.data.model.SpanRepresentation
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode
/**
* Tries to parse the plain body text from the text as well as parse all text formatting into
* [SpanRepresentation]s
*/
fun parseBodyAndSpansFromHtml(
html: String,
rootTag: String = "body",
useInnermostRootTag: Boolean = false,
brTagsAsNewLine: Boolean = true,
paragraphsAsNewLine: Boolean = false,
): Pair<String, MutableList<SpanRepresentation>> {
val document: Document = Jsoup.parse(html)
val rootNoteElement = document.body().getElementsByTag(rootTag)
if (rootNoteElement.isEmpty()) {
return Pair("", mutableListOf())
}
val rootElement =
if (useInnermostRootTag) rootNoteElement.last()!! else rootNoteElement.first()!!
val bodyText = StringBuilder()
val spans = mutableListOf<SpanRepresentation>()
processElement(
rootElement,
bodyText,
spans,
brTagsAsNewLine = brTagsAsNewLine,
paragraphsAsNewLine = paragraphsAsNewLine,
)
return Pair(bodyText.trimEnd().toString(), spans)
}
/**
* Adds plain text to [bodyText] and adds [SpanRepresentation]s:
* - `<b>` or `font-weight`>`400` -> [SpanRepresentation.bold]
* - `<i>` or `font-style:italic` -> [SpanRepresentation.italic]
* - `<s>` -> [SpanRepresentation.strikethrough]
* - `<a>` or text starting with `http` -> [SpanRepresentation.link]
* - `<span>` with `font-family` includes `monospace` or `Source Code Pro` ->
* [SpanRepresentation.monospace]
*/
private fun processElement(
element: Element,
bodyText: StringBuilder,
spans: MutableList<SpanRepresentation>,
brTagsAsNewLine: Boolean,
paragraphsAsNewLine: Boolean,
) {
for (child in element.childNodes()) {
when (child) {
is TextNode -> {
// If the child is a text node, append its text and update the offset
val text = child.text()
if (text != " ") {
bodyText.appendWithUrlCheck(text, spans)
}
}
is Element -> {
when (child.tagName()) {
"b" -> {
handleTextSpan(child, bodyText, spans, bold = true)
}
"i" -> {
handleTextSpan(child, bodyText, spans, italic = true)
}
"s" -> {
handleTextSpan(child, bodyText, spans, strikethrough = true)
}
"span" -> {
handleTextSpan(
child,
bodyText,
spans,
monospace = child.isMonospaceFont(),
bold = child.isBold(),
italic = child.isItalic(),
)
}
"a" -> {
handleTextSpan(
child,
bodyText,
spans,
link = true,
linkData = child.attr("href"),
)
}
"div" -> {
// div always means new-line, except for first
if (bodyText.isNotEmpty()) {
bodyText.append("\n")
}
processElement(
child,
bodyText,
spans,
brTagsAsNewLine = brTagsAsNewLine,
paragraphsAsNewLine = paragraphsAsNewLine,
)
}
"br" -> {
if (brTagsAsNewLine) {
bodyText.append("\n")
}
}
else -> {
processElement(child, bodyText, spans, brTagsAsNewLine, paragraphsAsNewLine)
if (
paragraphsAsNewLine &&
(child.tagName().startsWith("h") || child.tagName() == "p")
) {
bodyText.append("\n")
}
}
}
}
}
}
}
private fun handleTextSpan(
element: Element,
bodyText: StringBuilder,
spans: MutableList<SpanRepresentation>,
bold: Boolean = false,
italic: Boolean = false,
link: Boolean = false,
linkData: String? = null,
strikethrough: Boolean = false,
monospace: Boolean = false,
) {
val text = element.ownText()
if (bold || italic || link || strikethrough || monospace) {
val spanStart = bodyText.length
spans.add(
SpanRepresentation(
start = spanStart,
end = spanStart + text.length,
bold = bold,
link = link,
linkData = linkData,
italic = italic,
monospace = monospace,
strikethrough = strikethrough,
)
)
}
if (link) {
bodyText.append(text)
} else {
bodyText.appendWithUrlCheck(text, spans)
}
}
private fun StringBuilder.appendWithUrlCheck(text: String, spans: MutableList<SpanRepresentation>) {
checkForUrlSpan(text, spans, length)
append(text)
}
private fun checkForUrlSpan(
text: String,
spans: MutableList<SpanRepresentation>,
elementOffset: Int,
) {
val matcher = PatternsCompat.WEB_URL.matcher(text)
if (matcher.find() && matcher.group().startsWith("http")) {
val url = matcher.group()
spans.add(
SpanRepresentation(
start = elementOffset + matcher.start(),
end = elementOffset + matcher.end(),
link = true,
linkData = url,
)
)
}
}
private fun Element.isMonospaceFont(): Boolean {
val fontFamily = attr("style")
return fontFamily.contains("monospace", ignoreCase = true) ||
fontFamily.contains("Source Code Pro", ignoreCase = true)
}
private fun Element.isBold(): Boolean {
val style = attr("style")
return if (style.contains("font-weight")) {
val fontWeight: String = style.split("font-weight:")[1].split(";")[0].trim()
return fontWeight.toInt() > 400 // Google Keep normal text has fontWeight 400
} else false
}
private fun Element.isItalic(): Boolean {
val style = attr("style")
return style.contains("font-style:italic")
}

View file

@ -1,3 +0,0 @@
package com.philkes.notallyx.data.imports
class ImportException(val textResId: Int, cause: Throwable) : RuntimeException(cause)

View file

@ -1,17 +0,0 @@
package com.philkes.notallyx.data.imports
import com.philkes.notallyx.presentation.view.misc.Progress
open class ImportProgress(
current: Int = 0,
total: Int = 0,
inProgress: Boolean = true,
indeterminate: Boolean = false,
val stage: ImportStage = ImportStage.IMPORT_NOTES,
) : Progress(current, total, inProgress, indeterminate)
enum class ImportStage {
IMPORT_NOTES,
EXTRACT_FILES,
IMPORT_FILES,
}

View file

@ -1,167 +0,0 @@
package com.philkes.notallyx.data.imports
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter
import com.philkes.notallyx.data.imports.google.GoogleKeepImporter
import com.philkes.notallyx.data.imports.txt.JsonImporter
import com.philkes.notallyx.data.imports.txt.PlainTextImporter
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.importAudio
import com.philkes.notallyx.utils.backup.importFile
import com.philkes.notallyx.utils.backup.importImage
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
class NotesImporter(private val app: Application, private val database: NotallyDatabase) {
suspend fun import(
uri: Uri,
importSource: ImportSource,
progress: MutableLiveData<ImportProgress>? = null,
): Int {
val tempDir = File(app.cacheDir, IMPORT_CACHE_FOLDER)
if (!tempDir.exists()) {
tempDir.mkdirs()
}
try {
val (notes, importDataFolder) =
try {
when (importSource) {
ImportSource.GOOGLE_KEEP -> GoogleKeepImporter()
ImportSource.EVERNOTE -> EvernoteImporter()
ImportSource.PLAIN_TEXT -> PlainTextImporter()
ImportSource.JSON -> JsonImporter()
}.import(app, uri, tempDir, progress)
} catch (e: Exception) {
Log.e(TAG, "import: failed", e)
progress?.postValue(ImportProgress(inProgress = false))
throw e
}
database.getLabelDao().insert(notes.flatMap { it.labels }.distinct().map { Label(it) })
val files = notes.flatMap { it.files }.distinct()
val images = notes.flatMap { it.images }.distinct()
val audios = notes.flatMap { it.audios }.distinct()
val totalFiles = files.size + images.size + audios.size
val counter = AtomicInteger(1)
progress?.postValue(
ImportProgress(total = totalFiles, stage = ImportStage.IMPORT_FILES)
)
importDataFolder?.let {
importFiles(files, it, NotallyModel.FileType.ANY, progress, totalFiles, counter)
importFiles(images, it, NotallyModel.FileType.IMAGE, progress, totalFiles, counter)
importAudios(audios, it, progress, totalFiles, counter)
}
database.getBaseNoteDao().insert(notes)
progress?.postValue(ImportProgress(inProgress = false))
return notes.size
} finally {
tempDir.deleteRecursively()
}
}
private suspend fun importFiles(
files: List<FileAttachment>,
sourceFolder: File,
fileType: NotallyModel.FileType,
progress: MutableLiveData<ImportProgress>?,
total: Int?,
counter: AtomicInteger?,
) {
files.forEach { file ->
val uri = File(sourceFolder, file.localName).toUri()
val (fileAttachment, error) =
if (fileType == NotallyModel.FileType.IMAGE) app.importImage(uri, file.mimeType)
else app.importFile(uri, file.mimeType)
fileAttachment?.let {
file.localName = fileAttachment.localName
file.originalName = fileAttachment.originalName
file.mimeType = fileAttachment.mimeType
}
error?.let { Log.e(TAG, "Failed to import: $error") }
progress?.postValue(
ImportProgress(
current = counter!!.getAndIncrement(),
total = total!!,
stage = ImportStage.IMPORT_FILES,
)
)
}
}
private suspend fun importAudios(
audios: List<Audio>,
sourceFolder: File,
progress: MutableLiveData<ImportProgress>?,
totalFiles: Int,
counter: AtomicInteger,
) {
audios.forEach { originalAudio ->
val file = File(sourceFolder, originalAudio.name)
val audio = app.importAudio(file, false)
originalAudio.name = audio.name
originalAudio.duration = if (audio.duration == 0L) null else audio.duration
originalAudio.timestamp = audio.timestamp
progress?.postValue(
ImportProgress(
current = counter.getAndIncrement(),
total = totalFiles,
stage = ImportStage.IMPORT_FILES,
)
)
}
}
companion object {
private const val TAG = "NotesImporter"
const val IMPORT_CACHE_FOLDER = "imports"
}
}
enum class ImportSource(
val displayNameResId: Int,
val mimeType: String,
val helpTextResId: Int,
val documentationUrl: String?,
val iconResId: Int,
) {
GOOGLE_KEEP(
R.string.google_keep,
MIME_TYPE_ZIP,
R.string.google_keep_help,
"https://support.google.com/keep/answer/10017039",
R.drawable.icon_google_keep,
),
EVERNOTE(
R.string.evernote,
"*/*", // 'application/enex+xml' is not recognized
R.string.evernote_help,
"https://help.evernote.com/hc/en-us/articles/209005557-Export-notes-and-notebooks-as-ENEX-or-HTML",
R.drawable.icon_evernote,
),
PLAIN_TEXT(
R.string.plain_text_files,
FOLDER_OR_FILE_MIMETYPE,
R.string.plain_text_files_help,
null,
R.drawable.text_file,
),
JSON(
R.string.json_files,
FOLDER_OR_FILE_MIMETYPE,
R.string.json_files_help,
null,
R.drawable.file_json,
),
}
const val FOLDER_OR_FILE_MIMETYPE = "FOLDER_OR_FILE"

View file

@ -1,168 +0,0 @@
package com.philkes.notallyx.data.imports.evernote
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.Element
import org.simpleframework.xml.ElementList
import org.simpleframework.xml.Root
import org.simpleframework.xml.Text
import org.simpleframework.xml.convert.Convert
import org.simpleframework.xml.convert.Converter
import org.simpleframework.xml.stream.InputNode
import org.simpleframework.xml.stream.OutputNode
@Root(name = "en-export", strict = false)
data class EvernoteExport
@JvmOverloads
constructor(
@field:Attribute(name = "export-date", required = false)
@param:Attribute(name = "export-date", required = false)
val exportDate: String = "",
// @field:Attribute(name = "application", required = false)
// @param:Attribute(name = "application", required = false)
// val application: String = "",
// @field:Attribute(name = "version", required = false)
// @param:Attribute(name = "version", required = false)
// val version: String = "",
@field:ElementList(name = "note", inline = true)
@param:ElementList(name = "note", inline = true)
val notes: List<EvernoteNote> = listOf(),
)
@Root(name = "note", strict = false)
data class EvernoteNote
@JvmOverloads
constructor(
@field:Element(name = "title") @param:Element(name = "title") val title: String = "",
@field:Element(name = "created") @param:Element(name = "created") val created: String = "",
@field:Element(name = "updated") @param:Element(name = "updated") val updated: String = "",
@field:ElementList(name = "tag", inline = true, required = false, empty = false)
@param:ElementList(name = "tag", inline = true, required = false, empty = false)
var tag: List<EvernoteTag> = listOf(),
@field:Element(name = "content", data = true)
@param:Element(name = "content", data = true)
val content: String = "",
@field:ElementList(name = "resource", inline = true, required = false, empty = false)
@param:ElementList(name = "resource", inline = true, required = false, empty = false)
val resources: List<EvernoteResource> = listOf(),
@field:ElementList(name = "task", inline = true, required = false, empty = false)
@param:ElementList(name = "task", inline = true, required = false, empty = false)
val tasks: List<EvernoteTask> = listOf(),
)
@Root(name = "tag", strict = false)
data class EvernoteTag
@JvmOverloads
constructor(@field:Text(required = false) @param:Text(required = false) val name: String = "")
@Root(name = "resource", strict = false)
data class EvernoteResource
@JvmOverloads
constructor(
@field:Element(name = "data", required = false)
@param:Element(name = "data", required = false)
val data: EvernoteResourceData? = null,
@field:Element(name = "mime", required = false)
@param:Element(name = "mime", required = false)
val mime: String = "*/*",
@field:Element(name = "width", required = false)
@param:Element(name = "width", required = false)
val width: Int? = null,
@field:Element(name = "height", required = false)
@param:Element(name = "height", required = false)
val height: Int? = null,
@field:Element(name = "resource-attributes", required = false)
@param:Element(name = "resource-attributes", required = false)
val attributes: EvernoteResourceAttributes? = null,
)
@Root(name = "data", strict = false)
data class EvernoteResourceData
@JvmOverloads
constructor(
@field:Attribute(name = "encoding", required = false)
@param:Attribute(name = "encoding", required = false)
val encoding: String = "base64",
@field:Text(required = false) @param:Text(required = false) val content: String = "",
)
@Root(name = "resource-attributes", strict = false)
data class EvernoteResourceAttributes
@JvmOverloads
constructor(
@field:Element(name = "file-name", required = false)
@param:Element(name = "file-name", required = false)
val fileName: String = ""
// @field:Element(name = "source-url", required = false)
// @param:Element(name = "source-url", required = false)
// val sourceUrl: String= ""
)
@Root(name = "task", strict = false)
data class EvernoteTask
@JvmOverloads
constructor(
@field:Element(name = "title", required = false)
@param:Element(name = "title", required = false)
val title: String = "",
//
// @field:Element(name = "created") @param:Element(name = "created")
// val created: String = "",
//
// @field:Element(name = "updated") @param:Element(name = "updated")
// val updated: String = "",
//
@field:Element(name = "taskStatus", required = false)
@param:Element(name = "taskStatus", required = false)
@field:Convert(TaskStatusConverter::class)
@param:Convert(TaskStatusConverter::class)
val taskStatus: TaskStatus = TaskStatus.OPEN,
//
// @field:Element(name = "taskFlag") @param:Element(name = "taskFlag")
// val taskFlag: Boolean = false,
//
@field:Element(name = "sortWeight", required = false)
@param:Element(name = "sortWeight", required = false)
val sortWeight: String = "",
//
// @field:Element(name = "noteLevelID") @param:Element(name = "noteLevelID")
// val noteLevelID: String = "",
//
// @field:Element(name = "taskGroupNoteLevelID") @param:Element(name =
// "taskGroupNoteLevelID")
// val taskGroupNoteLevelID: String = "",
//
// @field:Element(name = "dueDate") @param:Element(name = "dueDate")
// val dueDate: String = "",
//
// @field:Element(name = "statusUpdated") @param:Element(name = "statusUpdated")
// val statusUpdated: String = "",
//
// @field:Element(name = "creator") @param:Element(name = "creator")
// val creator: String = "",
//
// @field:Element(name = "lastEditor") @param:Element(name = "lastEditor")
// val lastEditor: String = ""
)
enum class TaskStatus(val status: String) {
OPEN("open"),
COMPLETED("completed");
companion object {
fun fromString(value: String): TaskStatus? {
return entries.find { it.status == value }
}
}
}
class TaskStatusConverter : Converter<TaskStatus> {
override fun read(node: InputNode): TaskStatus? {
val value = node.value ?: return null
return TaskStatus.fromString(value)
}
override fun write(node: OutputNode, value: TaskStatus?) {
node.value = value?.status
}
}

View file

@ -1,190 +0,0 @@
package com.philkes.notallyx.data.imports.evernote
import android.app.Application
import android.net.Uri
import android.util.Base64
import android.webkit.MimeTypeMap
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportException
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.ImportStage
import com.philkes.notallyx.data.imports.evernote.EvernoteImporter.Companion.parseTimestamp
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.log
import com.philkes.notallyx.utils.startsWithAnyOf
import com.philkes.notallyx.utils.write
import java.io.File
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import org.simpleframework.xml.Serializer
import org.simpleframework.xml.convert.AnnotationStrategy
import org.simpleframework.xml.core.Persister
class EvernoteImporter : ExternalImporter {
private val serializer: Serializer = Persister(AnnotationStrategy())
override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File> {
progress?.postValue(ImportProgress(indeterminate = true))
if (MimeTypeMap.getFileExtensionFromUrl(source.toString()) != "enex") {
throw ImportException(
R.string.invalid_evernote,
IllegalArgumentException("Provided file is not in ENEX format"),
)
}
val evernoteExport: EvernoteExport =
parseExport(app.contentResolver.openInputStream(source)!!)!!
val total = evernoteExport.notes.size
progress?.postValue(ImportProgress(total = total))
var counter = 1
try {
val notes =
evernoteExport.notes.map {
val note = it.mapToBaseNote()
progress?.postValue(ImportProgress(current = counter++, total = total))
note
}
val resources =
evernoteExport.notes.flatMap { it.resources }.distinctBy { it.attributes?.fileName }
saveResourcesToFiles(app, resources, destination, progress)
return Pair(notes, destination)
} catch (e: Exception) {
throw ImportException(R.string.invalid_evernote, e)
}
}
fun parseExport(inputStream: InputStream): EvernoteExport? =
try {
serializer.read(EvernoteExport::class.java, inputStream)
} catch (e: Exception) {
throw ImportException(R.string.invalid_evernote, e)
}
private fun saveResourcesToFiles(
app: Application,
resources: Collection<EvernoteResource>,
dir: File,
progress: MutableLiveData<ImportProgress>? = null,
) {
progress?.postValue(
ImportProgress(total = resources.size, stage = ImportStage.EXTRACT_FILES)
)
resources.forEachIndexed { idx, it ->
val file = File(dir, it.attributes!!.fileName)
try {
val data = Base64.decode(it.data!!.content.trimStart(), Base64.DEFAULT)
file.write(data)
} catch (e: Exception) {
app.log(TAG, throwable = e)
}
progress?.postValue(
ImportProgress(
current = idx + 1,
total = resources.size,
stage = ImportStage.EXTRACT_FILES,
)
)
}
}
companion object {
private const val TAG = "EvernoteImporter"
fun parseTimestamp(timestamp: String): Long {
val format = SimpleDateFormat(EVERNOTE_DATE_FORMAT, Locale.getDefault())
format.timeZone = TimeZone.getTimeZone("UTC")
return try {
val date: Date = format.parse(timestamp) ?: Date()
date.time // Return milliseconds since epoch
} catch (e: Exception) {
System.currentTimeMillis()
}
}
}
}
private const val EVERNOTE_DATE_FORMAT = "yyyyMMdd'T'HHmmss'Z'"
private const val EVERNOTE_NOTE_XML_TAG = "en-note"
fun EvernoteNote.mapToBaseNote(): BaseNote {
val (body, spans) =
parseBodyAndSpansFromHtml(
content,
EVERNOTE_NOTE_XML_TAG,
useInnermostRootTag = true,
brTagsAsNewLine = false,
)
val images = resources.filterByMimeTypePrefix("image").toFileAttachments()
val files = resources.filterByExcludedMimeTypePrefixes("image", "audio").toFileAttachments()
val audios =
resources.filterByMimeTypePrefix("audio").map {
Audio(it.attributes!!.fileName, -1, System.currentTimeMillis())
}
return BaseNote(
0L,
type = if (tasks.isEmpty()) Type.NOTE else Type.LIST,
folder = Folder.NOTES, // There is no archive in Evernote, also deleted notes are not
// exported
color = BaseNote.COLOR_DEFAULT, // TODO: possible in Evernote?
title = title,
pinned = false, // not exported from Evernote
timestamp = parseTimestamp(created),
modifiedTimestamp = parseTimestamp(updated),
labels = tag.map { it.name },
body = body,
spans = spans,
items = tasks.mapToListItem(),
images = images,
files = files,
audios = audios,
reminders = mutableListOf(),
NoteViewMode.EDIT,
)
}
fun Collection<EvernoteResource>.filterByMimeTypePrefix(
mimeTypePrefix: String
): List<EvernoteResource> {
return filter { it.mime.startsWith(mimeTypePrefix) }
}
fun Collection<EvernoteResource>.filterByExcludedMimeTypePrefixes(
vararg mimeTypePrefix: String
): List<EvernoteResource> {
return filter { !it.mime.startsWithAnyOf(*mimeTypePrefix) }
}
fun Collection<EvernoteResource>.toFileAttachments(): List<FileAttachment> {
return map { FileAttachment(it.attributes!!.fileName, it.attributes.fileName, it.mime) }
}
fun Collection<EvernoteTask>.mapToListItem(): List<ListItem> {
return sortedBy { it.sortWeight }
.mapIndexed { index, evernoteTask ->
ListItem(
body = evernoteTask.title,
checked = evernoteTask.taskStatus == TaskStatus.COMPLETED,
isChild = false, // You cant indent tasks in Evernote
order = index,
children = mutableListOf(),
)
}
}

View file

@ -1,218 +0,0 @@
package com.philkes.notallyx.data.imports.google
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportException
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.ImportStage
import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.listFilesRecursive
import com.philkes.notallyx.utils.log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
class GoogleKeepImporter : ExternalImporter {
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
allowTrailingComma = true
}
override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File> {
progress?.postValue(ImportProgress(indeterminate = true, stage = ImportStage.EXTRACT_FILES))
val dataFolder =
try {
app.contentResolver.openInputStream(source)!!.use { unzip(destination, it) }
} catch (e: Exception) {
throw ImportException(R.string.invalid_google_keep, e)
}
if (!dataFolder.exists()) {
throw ImportException(
R.string.invalid_google_keep,
RuntimeException("Extracting Takeout.zip failed"),
)
}
val noteFiles =
dataFolder
.listFilesRecursive { file ->
file.isFile && file.extension.equals("json", ignoreCase = true)
}
.toList()
val total = noteFiles.size
progress?.postValue(ImportProgress(0, total, stage = ImportStage.IMPORT_NOTES))
var counter = 1
val baseNotes =
noteFiles
.mapNotNull { file ->
val baseNote =
try {
val relativePath = file.parentFile!!.toRelativeString(dataFolder)
file.readText().parseToBaseNote(relativePath)
} catch (e: Exception) {
app.log(
TAG,
msg =
"Could not parse BaseNote from JSON in file '${file.absolutePath}'",
throwable = e,
)
null
}
progress?.postValue(
ImportProgress(counter++, total, stage = ImportStage.IMPORT_NOTES)
)
baseNote
}
.toList()
return Pair(baseNotes, dataFolder)
}
fun String.parseToBaseNote(relativePath: String? = null): BaseNote {
val googleKeepNote = json.decodeFromString<GoogleKeepNote>(this)
val (body, spans) =
parseBodyAndSpansFromHtml(
googleKeepNote.textContentHtml,
paragraphsAsNewLine = true,
brTagsAsNewLine = true,
)
val images =
googleKeepNote.attachments
.filter { it.mimetype.startsWith("image") }
.map { attachment ->
FileAttachment(
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
attachment.filePath,
attachment.mimetype,
)
}
val files =
googleKeepNote.attachments
.filter { !it.mimetype.startsWith("audio") && !it.mimetype.startsWith("image") }
.map { attachment ->
FileAttachment(
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
attachment.filePath,
attachment.mimetype,
)
}
val audios =
googleKeepNote.attachments
.filter { it.mimetype.startsWith("audio") }
.map { attachment ->
Audio(
"${relativePath?.let { "$it/" } ?: ""}${attachment.filePath}",
0L,
System.currentTimeMillis(),
)
}
val items =
googleKeepNote.listContent.mapIndexed { index, item ->
ListItem(
body = item.text,
checked = item.isChecked,
isChild = false, // Google Keep doesn't have explicit child/indentation info
order = index,
children = mutableListOf(),
)
}
return BaseNote(
id = 0L, // Auto-generated
type = if (googleKeepNote.listContent.isNotEmpty()) Type.LIST else Type.NOTE,
folder =
when {
googleKeepNote.isTrashed -> Folder.DELETED
googleKeepNote.isArchived -> Folder.ARCHIVED
else -> Folder.NOTES
},
color = BaseNote.COLOR_DEFAULT, // Ignoring color mapping
title = googleKeepNote.title,
pinned = googleKeepNote.isPinned,
timestamp = googleKeepNote.createdTimestampUsec / 1000,
modifiedTimestamp = googleKeepNote.userEditedTimestampUsec / 1000,
labels = googleKeepNote.labels.map { it.name },
body = body,
spans = spans,
items = items,
images = images,
files = files,
audios = audios,
reminders = mutableListOf(),
NoteViewMode.EDIT,
)
}
private fun newFile(destinationDir: File, zipEntry: ZipEntry): File {
val destFile = File(destinationDir, zipEntry.name)
val destDirPath = destinationDir.canonicalPath
val destFilePath = destFile.canonicalPath
if (!destFilePath.startsWith(destDirPath + File.separator)) {
throw IOException("Entry is outside of the target dir: " + zipEntry.name)
}
return destFile
}
private fun unzip(destinationPath: File, inputStream: InputStream): File {
val buffer = ByteArray(1024)
val zis = ZipInputStream(inputStream)
var zipEntry = zis.nextEntry
while (zipEntry != null) {
val newFile: File = newFile(destinationPath, zipEntry)
if (zipEntry.isDirectory) {
if (!newFile.isDirectory && !newFile.mkdirs()) {
throw IOException("Failed to create directory $newFile")
}
} else {
val parent = newFile.parentFile
if (parent != null) {
if (!parent.isDirectory && !parent.mkdirs()) {
throw IOException("Failed to create directory $parent")
}
}
FileOutputStream(newFile).use {
var len: Int
while ((zis.read(buffer).also { length -> len = length }) > 0) {
it.write(buffer, 0, len)
}
}
}
zipEntry = zis.nextEntry
}
zis.closeEntry()
zis.close()
return destinationPath
}
companion object {
private const val TAG = "GoogleKeepImporter"
}
}

View file

@ -1,26 +0,0 @@
package com.philkes.notallyx.data.imports.google
import com.philkes.notallyx.data.model.BaseNote
import kotlinx.serialization.Serializable
@Serializable
data class GoogleKeepNote(
val attachments: List<GoogleKeepAttachment> = listOf(),
val color: String = BaseNote.COLOR_DEFAULT,
val isTrashed: Boolean = false,
val isArchived: Boolean = false,
val isPinned: Boolean = false,
val textContent: String = "",
val textContentHtml: String = "",
val title: String = "",
val labels: List<GoogleKeepLabel> = listOf(),
val userEditedTimestampUsec: Long = System.currentTimeMillis(),
val createdTimestampUsec: Long = System.currentTimeMillis(),
val listContent: List<GoogleKeepListItem> = listOf(),
)
@Serializable data class GoogleKeepLabel(val name: String)
@Serializable data class GoogleKeepAttachment(val filePath: String, val mimetype: String)
@Serializable data class GoogleKeepListItem(val text: String, val isChecked: Boolean)

View file

@ -1,52 +0,0 @@
package com.philkes.notallyx.data.imports.txt
import android.app.Application
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.toBaseNote
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
class JsonImporter : ExternalImporter {
override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File?> {
val notes = mutableListOf<BaseNote>()
fun readJsonFiles(file: DocumentFile) {
when {
file.isDirectory -> {
file.listFiles().forEach { readJsonFiles(it) }
}
file.isFile -> {
if (file.type != MIME_TYPE_JSON) {
return
}
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
val content =
app.contentResolver.openInputStream(file.uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
} ?: ""
notes.add(content.toBaseNote().copy(id = 0L, title = fileNameWithoutExtension))
}
}
}
val file =
if (source.pathSegments.firstOrNull() == "tree") {
DocumentFile.fromTreeUri(app, source)
} else DocumentFile.fromSingleUri(app, source)
file?.let { readJsonFiles(it) }
return Pair(notes, null)
}
}

View file

@ -1,42 +0,0 @@
package com.philkes.notallyx.data.imports.txt
import com.philkes.notallyx.data.model.ListItem
fun CharSequence.extractListItems(regex: Regex): List<ListItem> {
return regex
.findAll(this)
.mapIndexedNotNull { idx, matchResult ->
val isChild = matchResult.groupValues[1] != ""
val isChecked = matchResult.groupValues[2] != ""
val itemText = matchResult.groupValues[3]
if (itemText.isNotBlank()) {
ListItem(itemText.trimStart(), isChecked, isChild, idx, mutableListOf())
} else null
}
.toList()
}
fun CharSequence.findListSyntaxRegex(
checkContains: Boolean = false,
plainNewLineAllowed: Boolean = false,
): Regex? {
val checkCallback: (String) -> Boolean =
if (checkContains) {
{ string -> startsWith(string) || contains(string, ignoreCase = true) }
} else {
{ string -> startsWith(string) }
}
if (checkCallback("- [ ]") || checkCallback("- [x]")) {
return "\n?(\\s*)-? ?\\[? ?([xX]?)\\]?(.*)".toRegex()
}
if (checkCallback("[ ]") || checkCallback("[✓]")) {
return "\n?(\\s*)\\[? ?(✓?)\\]?(.*)".toRegex()
}
if (checkCallback("-") || checkCallback("*")) {
return "\n?(\\s*)[-*]?\\s*()(.*)".toRegex()
}
if (plainNewLineAllowed && contains("\n")) {
return "\n?(\\s*)()(.*)".toRegex()
}
return null
}

View file

@ -1,89 +0,0 @@
package com.philkes.notallyx.data.imports.txt
import android.app.Application
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.readFileContents
import java.io.File
class PlainTextImporter : ExternalImporter {
override fun import(
app: Application,
source: Uri,
destination: File,
progress: MutableLiveData<ImportProgress>?,
): Pair<List<BaseNote>, File?> {
val notes = mutableListOf<BaseNote>()
fun readTxtFiles(file: DocumentFile) {
when {
file.isDirectory -> {
file.listFiles().forEach { readTxtFiles(it) }
}
file.isFile -> {
if (file.type?.isTextMimeType() == false) {
return
}
val fileNameWithoutExtension = file.name?.substringBeforeLast(".") ?: ""
var content = app.contentResolver.readFileContents(file.uri)
val listItems = mutableListOf<ListItem>()
content.findListSyntaxRegex()?.let { listSyntaxRegex ->
listItems.addAll(content.extractListItems(listSyntaxRegex))
content = ""
}
val timestamp = System.currentTimeMillis()
notes.add(
BaseNote(
id = 0L, // Auto-generated
type = if (listItems.isEmpty()) Type.NOTE else Type.LIST,
folder = Folder.NOTES,
color = BaseNote.COLOR_DEFAULT,
title = fileNameWithoutExtension,
pinned = false,
timestamp = timestamp,
modifiedTimestamp = timestamp,
labels = listOf(),
body = content,
spans = listOf(),
items = listItems,
images = listOf(),
files = listOf(),
audios = listOf(),
reminders = listOf(),
NoteViewMode.EDIT,
)
)
}
}
}
val file =
if (source.pathSegments.firstOrNull() == "tree") {
DocumentFile.fromTreeUri(app, source)
} else DocumentFile.fromSingleUri(app, source)
file?.let { readTxtFiles(it) }
return Pair(notes, null)
}
private fun String.isTextMimeType(): Boolean {
return startsWith("text/") || this in APPLICATION_TEXT_MIME_TYPES
}
}
val APPLICATION_TEXT_MIME_TYPES =
arrayOf(
MIME_TYPE_JSON,
"application/xml",
"application/javascript",
"application/xhtml+xml",
"application/yaml",
)

View file

@ -2,5 +2,4 @@ package com.philkes.notallyx.data.model
import kotlinx.parcelize.Parcelize
@Parcelize
data class Audio(var name: String, var duration: Long?, var timestamp: Long) : Attachment
@Parcelize data class Audio(var name: String, val duration: Long, val timestamp: Long) : Attachment

View file

@ -4,15 +4,12 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** Format: `#RRGGBB` or `#AARRGGBB` or [BaseNote.COLOR_DEFAULT] */
typealias ColorString = String
@Entity(indices = [Index(value = ["id", "folder", "pinned", "timestamp", "labels"])])
data class BaseNote(
@PrimaryKey(autoGenerate = true) val id: Long,
val type: Type,
val folder: Folder,
val color: ColorString,
val color: Color,
val title: String,
val pinned: Boolean,
val timestamp: Long,
@ -24,70 +21,4 @@ data class BaseNote(
val images: List<FileAttachment>,
val files: List<FileAttachment>,
val audios: List<Audio>,
val reminders: List<Reminder>,
val viewMode: NoteViewMode,
) : Item {
companion object {
const val COLOR_DEFAULT = "DEFAULT"
const val COLOR_NEW = "NEW"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BaseNote
if (id != other.id) return false
if (type != other.type) return false
if (folder != other.folder) return false
if (color != other.color) return false
if (title != other.title) return false
if (pinned != other.pinned) return false
if (timestamp != other.timestamp) return false
if (labels != other.labels) return false
if (body != other.body) return false
if (spans != other.spans) return false
if (items != other.items) return false
if (images != other.images) return false
if (files != other.files) return false
if (audios != other.audios) return false
if (reminders != other.reminders) return false
if (viewMode != other.viewMode) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + folder.hashCode()
result = 31 * result + color.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + pinned.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + labels.hashCode()
result = 31 * result + body.hashCode()
result = 31 * result + spans.hashCode()
result = 31 * result + items.hashCode()
result = 31 * result + images.hashCode()
result = 31 * result + files.hashCode()
result = 31 * result + audios.hashCode()
result = 31 * result + reminders.hashCode()
result = 31 * result + viewMode.hashCode()
return result
}
}
fun BaseNote.deepCopy(): BaseNote {
return copy(
labels = labels.toMutableList(),
spans = spans.map { it.copy() }.toMutableList(),
items = items.map { it.copy() }.toMutableList(),
images = images.map { it.copy() }.toMutableList(),
files = files.map { it.copy() }.toMutableList(),
audios = audios.map { it.copy() }.toMutableList(),
reminders = reminders.map { it.copy() }.toMutableList(),
)
}
) : Item

View file

@ -12,45 +12,5 @@ enum class Color {
DUSK,
FLOWER,
BLOSSOM,
CLAY;
companion object {
fun allColorStrings() = entries.map { it.toColorString() }.toList()
fun valueOfOrDefault(value: String) =
try {
Color.valueOf(value)
} catch (e: Exception) {
DEFAULT
}
}
CLAY,
}
fun Color.toColorString() =
when (this) {
Color.DEFAULT -> BaseNote.COLOR_DEFAULT
Color.CORAL -> "#FAAFA9"
Color.ORANGE -> "#FFCC80"
Color.SAND -> "#FFF8B9"
Color.STORM -> "#AFCCDC"
Color.FOG -> "#D3E4EC"
Color.SAGE -> "#B4DED4"
Color.MINT -> "#E2F6D3"
Color.DUSK -> "#D3BFDB"
Color.FLOWER -> "#F8BBD0"
Color.BLOSSOM -> "#F5E2DC"
Color.CLAY -> "#E9E3D3"
}
fun String.parseToColorString() =
try {
android.graphics.Color.parseColor(this)
this
} catch (_: Exception) {
try {
val colorEnum = Color.valueOf(this)
colorEnum.toColorString()
} catch (e: Exception) {
BaseNote.COLOR_DEFAULT
}
}

View file

@ -1,7 +1,6 @@
package com.philkes.notallyx.data.model
import androidx.room.TypeConverter
import java.util.Date
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
@ -10,9 +9,7 @@ object Converters {
@TypeConverter fun labelsToJson(labels: List<String>) = JSONArray(labels).toString()
@TypeConverter fun jsonToLabels(json: String) = jsonToLabels(JSONArray(json))
fun jsonToLabels(jsonArray: JSONArray) = jsonArray.iterable<String>().toList()
@TypeConverter fun jsonToLabels(json: String) = JSONArray(json).iterable<String>().toList()
@TypeConverter
fun filesToJson(files: List<FileAttachment>): String {
@ -26,10 +23,10 @@ object Converters {
return JSONArray(objects).toString()
}
@TypeConverter fun jsonToFiles(json: String) = jsonToFiles(JSONArray(json))
fun jsonToFiles(jsonArray: JSONArray): List<FileAttachment> {
return jsonArray.iterable<JSONObject>().map { jsonObject ->
@TypeConverter
fun jsonToFiles(json: String): List<FileAttachment> {
val iterable = JSONArray(json).iterable<JSONObject>()
return iterable.map { jsonObject ->
val localName = getSafeLocalName(jsonObject)
val originalName = getSafeOriginalName(jsonObject)
val mimeType = jsonObject.getString("mimeType")
@ -49,74 +46,42 @@ object Converters {
return JSONArray(objects).toString()
}
@TypeConverter fun jsonToAudios(json: String) = jsonToAudios(JSONArray(json))
fun jsonToAudios(json: JSONArray): List<Audio> {
return json.iterable<JSONObject>().map { jsonObject ->
@TypeConverter
fun jsonToAudios(json: String): List<Audio> {
val iterable = JSONArray(json).iterable<JSONObject>()
return iterable.map { jsonObject ->
val name = jsonObject.getString("name")
val duration = jsonObject.getSafeLong("duration")
val duration = jsonObject.getLong("duration")
val timestamp = jsonObject.getLong("timestamp")
Audio(name, duration, timestamp)
}
}
@TypeConverter fun jsonToSpans(json: String) = jsonToSpans(JSONArray(json))
fun jsonToSpans(jsonArray: JSONArray): List<SpanRepresentation> {
return jsonArray
.iterable<JSONObject>()
.map { jsonObject ->
val bold = jsonObject.getSafeBoolean("bold")
val link = jsonObject.getSafeBoolean("link")
val linkData = jsonObject.getSafeString("linkData")
val italic = jsonObject.getSafeBoolean("italic")
val monospace = jsonObject.getSafeBoolean("monospace")
val strikethrough = jsonObject.getSafeBoolean("strikethrough")
try {
val start = jsonObject.getInt("start")
val end = jsonObject.getInt("end")
SpanRepresentation(
start,
end,
bold,
link,
linkData,
italic,
monospace,
strikethrough,
)
} catch (e: Exception) {
null
}
}
.filterNotNull()
@TypeConverter
fun jsonToSpans(json: String): List<SpanRepresentation> {
val iterable = JSONArray(json).iterable<JSONObject>()
return iterable.map { jsonObject ->
val bold = jsonObject.getSafeBoolean("bold")
val link = jsonObject.getSafeBoolean("link")
val linkData = jsonObject.getSafeString("linkData")
val italic = jsonObject.getSafeBoolean("italic")
val monospace = jsonObject.getSafeBoolean("monospace")
val strikethrough = jsonObject.getSafeBoolean("strikethrough")
val start = jsonObject.getInt("start")
val end = jsonObject.getInt("end")
SpanRepresentation(bold, link, linkData, italic, monospace, strikethrough, start, end)
}
}
@TypeConverter
fun spansToJson(list: List<SpanRepresentation>) = spansToJSONArray(list).toString()
fun spansToJSONArray(list: List<SpanRepresentation>): JSONArray {
val objects =
list.map { representation ->
val jsonObject = JSONObject()
jsonObject.put("bold", representation.bold)
jsonObject.put("link", representation.link)
jsonObject.put("linkData", representation.linkData)
jsonObject.put("italic", representation.italic)
jsonObject.put("monospace", representation.monospace)
jsonObject.put("strikethrough", representation.strikethrough)
jsonObject.put("start", representation.start)
jsonObject.put("end", representation.end)
}
return JSONArray(objects)
}
@TypeConverter fun jsonToItems(json: String) = jsonToItems(JSONArray(json))
fun jsonToItems(json: JSONArray): List<ListItem> {
return json.iterable<JSONObject>().map { jsonObject ->
val body = jsonObject.getSafeString("body") ?: ""
val checked = jsonObject.getSafeBoolean("checked")
@TypeConverter
fun jsonToItems(json: String): List<ListItem> {
val iterable = JSONArray(json).iterable<JSONObject>()
return iterable.map { jsonObject ->
val body = jsonObject.getString("body")
val checked = jsonObject.getBoolean("checked")
val isChild = jsonObject.getSafeBoolean("isChild")
val order = jsonObject.getSafeInt("order")
ListItem(body, checked, isChild, order, mutableListOf())
@ -137,55 +102,22 @@ object Converters {
return JSONArray(objects)
}
@TypeConverter
fun remindersToJson(reminders: List<Reminder>) = remindersToJSONArray(reminders).toString()
fun remindersToJSONArray(reminders: List<Reminder>): JSONArray {
fun spansToJSONArray(list: List<SpanRepresentation>): JSONArray {
val objects =
reminders.map { reminder ->
JSONObject().apply {
put("id", reminder.id) // Store date as long timestamp
put("dateTime", reminder.dateTime.time) // Store date as long timestamp
put("repetition", reminder.repetition?.let { repetitionToJsonObject(it) })
}
list.map { representation ->
val jsonObject = JSONObject()
jsonObject.put("bold", representation.bold)
jsonObject.put("link", representation.link)
jsonObject.put("linkData", representation.linkData)
jsonObject.put("italic", representation.italic)
jsonObject.put("monospace", representation.monospace)
jsonObject.put("strikethrough", representation.strikethrough)
jsonObject.put("start", representation.start)
jsonObject.put("end", representation.end)
}
return JSONArray(objects)
}
@TypeConverter fun jsonToReminders(json: String) = jsonToReminders(JSONArray(json))
fun jsonToReminders(jsonArray: JSONArray): List<Reminder> {
return jsonArray.iterable<JSONObject>().map { jsonObject ->
val id = jsonObject.getLong("id")
val dateTime = Date(jsonObject.getLong("dateTime"))
val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) }
Reminder(id, dateTime, repetition)
}
}
@TypeConverter
fun repetitionToJson(repetition: Repetition): String {
return repetitionToJsonObject(repetition).toString()
}
fun repetitionToJsonObject(repetition: Repetition): JSONObject {
val jsonObject = JSONObject()
jsonObject.put("value", repetition.value)
jsonObject.put("unit", repetition.unit.name) // Store the TimeUnit as a string
return jsonObject
}
@TypeConverter
fun jsonToRepetition(json: String): Repetition {
val jsonObject = JSONObject(json)
val value = jsonObject.getInt("value")
val unit =
RepetitionTimeUnit.valueOf(
jsonObject.getString("unit")
) // Convert string back to TimeUnit
return Repetition(value, unit)
}
private fun getSafeLocalName(jsonObject: JSONObject): String {
return try {
jsonObject.getString("localName")
@ -226,14 +158,6 @@ object Converters {
}
}
private fun JSONObject.getSafeLong(name: String): Long? {
return try {
getLong(name)
} catch (exception: JSONException) {
null
}
}
private fun <T> JSONArray.iterable() = Iterable {
object : Iterator<T> {
var index = 0

View file

@ -3,5 +3,5 @@ package com.philkes.notallyx.data.model
import kotlinx.parcelize.Parcelize
@Parcelize
data class FileAttachment(var localName: String, var originalName: String, var mimeType: String) :
data class FileAttachment(var localName: String, var originalName: String, val mimeType: String) :
Attachment

View file

@ -1,18 +1,7 @@
package com.philkes.notallyx.data.model
import java.io.Serializable
enum class Folder : Serializable {
enum class Folder {
NOTES,
DELETED,
ARCHIVED;
companion object {
fun valueOfOrDefault(value: String) =
try {
valueOf(value)
} catch (e: Exception) {
NOTES
}
}
ARCHIVED,
}

View file

@ -35,7 +35,6 @@ data class ListItem(
return false
}
return (this.body == other.body &&
this.order == other.order &&
this.checked == other.checked &&
this.isChild == other.isChild)
}

View file

@ -1,7 +1,5 @@
package com.philkes.notallyx.data.model
import com.philkes.notallyx.presentation.view.note.listitem.areAllChecked
operator fun ListItem.plus(list: List<ListItem>): List<ListItem> {
return mutableListOf(this) + list
}
@ -10,17 +8,35 @@ fun ListItem.findChild(childId: Int): ListItem? {
return this.children.find { child -> child.id == childId }
}
fun ListItem.check(checked: Boolean, checkChildren: Boolean = true) {
this.checked = checked
if (checkChildren) {
this.children.forEach { child -> child.checked = checked }
fun List<ListItem>.areAllChecked(except: ListItem? = null): Boolean {
return this.none { !it.checked && it != except }
}
fun MutableList<ListItem>.containsId(id: Int): Boolean {
return this.any { it.id == id }
}
fun Collection<ListItem>.toReadableString(): String {
return map { "$it uncheckedPos: ${it.order} id: ${it.id}" }.joinToString("\n")
}
fun List<ListItem>.findChildrenPositions(parentPosition: Int): List<Int> {
val childrenPositions = mutableListOf<Int>()
for (position in parentPosition + 1 until this.size) {
if (this[position].isChild) {
childrenPositions.add(position)
} else {
break
}
}
return childrenPositions
}
fun ListItem.shouldParentBeUnchecked(): Boolean {
return children.isNotEmpty() && !children.areAllChecked() && checked
}
fun ListItem.shouldParentBeChecked(): Boolean {
return children.isNotEmpty() && children.areAllChecked() && !checked
fun List<ListItem>.findParentPosition(childPosition: Int): Int? {
for (position in childPosition - 1 downTo 0) {
if (!this[position].isChild) {
return position
}
}
return null
}

View file

@ -1,322 +0,0 @@
package com.philkes.notallyx.data.model
import android.content.Context
import android.text.Html
import androidx.core.text.toHtml
import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteIdReminder
import com.philkes.notallyx.data.model.BaseNote.Companion.COLOR_DEFAULT
import com.philkes.notallyx.presentation.applySpans
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
private const val NOTE_URL_PREFIX = "note://"
private val NOTE_URL_POSTFIX_NOTE = "/${Type.NOTE.name}"
private val NOTE_URL_POSTFIX_LIST = "/${Type.LIST.name}"
fun CharSequence?.isNoteUrl(): Boolean {
return this?.let { startsWith(NOTE_URL_PREFIX) } ?: false
}
fun Long.createNoteUrl(type: Type): String {
val postfix =
when (type) {
Type.LIST -> NOTE_URL_POSTFIX_LIST
Type.NOTE -> NOTE_URL_POSTFIX_NOTE
}
return "$NOTE_URL_PREFIX$this$postfix"
}
fun String.getNoteIdFromUrl(): Long {
return substringAfter(NOTE_URL_PREFIX).substringBefore("/").toLong()
}
fun String.getNoteTypeFromUrl(): Type {
return Type.valueOf(substringAfterLast("/"))
}
val FileAttachment.isImage: Boolean
get() {
return mimeType.isImageMimeType
}
val String.isImageMimeType: Boolean
get() {
return startsWith("image/")
}
val String.isAudioMimeType: Boolean
get() {
return startsWith("audio/")
}
fun BaseNote.toTxt(includeTitle: Boolean = true, includeCreationDate: Boolean = true) =
buildString {
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
val body =
when (type) {
Type.NOTE -> body
Type.LIST -> items.toText()
}
if (title.isNotEmpty() && includeTitle) {
append("${title}\n\n")
}
if (includeCreationDate) {
append("$date\n\n")
}
append(body)
return toString()
}
fun BaseNote.toJson(): String {
val jsonObject =
JSONObject()
.put("type", type.name)
.put("color", color)
.put("title", title)
.put("pinned", pinned)
.put("timestamp", timestamp)
.put("modifiedTimestamp", modifiedTimestamp)
.put("labels", JSONArray(labels))
when (type) {
Type.NOTE -> {
jsonObject.put("body", body)
jsonObject.put("spans", Converters.spansToJSONArray(spans))
}
Type.LIST -> {
jsonObject.put("items", Converters.itemsToJSONArray(items))
}
}
jsonObject.put("reminders", Converters.remindersToJSONArray(reminders))
jsonObject.put("viewMode", viewMode.name)
return jsonObject.toString(2)
}
fun String.toBaseNote(): BaseNote {
val jsonObject = JSONObject(this)
val id = jsonObject.getLongOrDefault("id", -1L)
val type = Type.valueOfOrDefault(jsonObject.getStringOrDefault("type", Type.NOTE.name))
val folder = Folder.valueOfOrDefault(jsonObject.getStringOrDefault("folder", Folder.NOTES.name))
val color =
jsonObject.getStringOrDefault("color", COLOR_DEFAULT).takeIf { it.isValid() }
?: COLOR_DEFAULT
val title = jsonObject.getStringOrDefault("title", "")
val pinned = jsonObject.getBooleanOrDefault("pinned", false)
val timestamp = jsonObject.getLongOrDefault("timestamp", System.currentTimeMillis())
val modifiedTimestamp = jsonObject.getLongOrDefault("modifiedTimestamp", timestamp)
val labels = Converters.jsonToLabels(jsonObject.getArrayOrEmpty("labels"))
val body = jsonObject.getStringOrDefault("body", "")
val spans = Converters.jsonToSpans(jsonObject.getArrayOrEmpty("spans"))
val items = Converters.jsonToItems(jsonObject.getArrayOrEmpty("items"))
val images = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("images"))
val files = Converters.jsonToFiles(jsonObject.getArrayOrEmpty("files"))
val audios = Converters.jsonToAudios(jsonObject.getArrayOrEmpty("audios"))
val reminders = Converters.jsonToReminders(jsonObject.getArrayOrEmpty("reminders"))
val viewMode = NoteViewMode.valueOfOrDefault(jsonObject.getStringOrDefault("viewMode", ""))
return BaseNote(
id,
type,
folder,
color,
title,
pinned,
timestamp,
modifiedTimestamp,
labels,
body,
spans,
items,
images,
files,
audios,
reminders,
viewMode,
)
}
private fun JSONObject.getStringOrDefault(key: String, defaultValue: String): String {
return try {
getString(key)
} catch (exception: JSONException) {
defaultValue
}
}
private fun JSONObject.getArrayOrEmpty(key: String): JSONArray {
return try {
getJSONArray(key)
} catch (exception: JSONException) {
JSONArray("[]")
}
}
private fun JSONObject.getBooleanOrDefault(key: String, defaultValue: Boolean): Boolean {
return try {
getBoolean(key)
} catch (exception: JSONException) {
defaultValue
}
}
private fun JSONObject.getLongOrDefault(key: String, defaultValue: Long): Long {
return try {
getLong(key)
} catch (exception: JSONException) {
defaultValue
}
}
fun BaseNote.toHtml(showDateCreated: Boolean) = buildString {
val date = DateFormat.getDateInstance(DateFormat.FULL).format(timestamp)
val title = Html.escapeHtml(title)
append("<!DOCTYPE html>")
append("<html><head>")
append("<meta charset=\"UTF-8\"><title>$title</title>")
append("</head><body>")
append("<h2>$title</h2>")
if (showDateCreated) {
append("<p>$date</p>")
}
when (type) {
Type.NOTE -> {
val body = body.applySpans(spans).toHtml()
append(body)
}
Type.LIST -> {
append("<ol style=\"list-style: none; padding: 0;\">")
items.forEach { item ->
val body = Html.escapeHtml(item.body)
val checked = if (item.checked) "checked" else ""
val child = if (item.isChild) "style=\"margin-left: 20px\"" else ""
append("<li><input type=\"checkbox\" $child $checked>$body</li>")
}
append("</ol>")
}
}
append("</body></html>")
}
fun List<BaseNote>.toNoteIdReminders() = map { NoteIdReminder(it.id, it.reminders) }
fun BaseNote.attachmentsDifferFrom(other: BaseNote): Boolean {
return files.size != other.files.size ||
files.any { file -> other.files.none { it.localName == file.localName } } ||
other.files.any { file -> files.none { it.localName == file.localName } } ||
images.any { image -> other.images.none { it.localName == image.localName } } ||
other.images.any { image -> images.none { it.localName == image.localName } } ||
audios.any { audio -> other.audios.none { it.name == audio.name } } ||
other.audios.any { audio -> audios.none { it.name == audio.name } }
}
fun Date.toText(): String = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(this)
fun Repetition.toText(context: Context): String =
when {
value == 1 && unit == RepetitionTimeUnit.DAYS -> context.getString(R.string.daily)
value == 1 && unit == RepetitionTimeUnit.WEEKS -> context.getString(R.string.weekly)
value == 1 && unit == RepetitionTimeUnit.MONTHS -> context.getString(R.string.monthly)
value == 1 && unit == RepetitionTimeUnit.YEARS -> context.getString(R.string.yearly)
else -> "${context.getString(R.string.every)} $value ${unit.toText(context)}"
}
private fun RepetitionTimeUnit.toText(context: Context): String {
val resId =
when (this) {
RepetitionTimeUnit.MINUTES -> R.string.minutes
RepetitionTimeUnit.HOURS -> R.string.hours
RepetitionTimeUnit.DAYS -> R.string.days
RepetitionTimeUnit.WEEKS -> R.string.weeks
RepetitionTimeUnit.MONTHS -> R.string.months
RepetitionTimeUnit.YEARS -> R.string.years
}
return context.getString(resId)
}
fun Collection<Reminder>.copy() = map { it.copy() }
fun RepetitionTimeUnit.toCalendarField(): Int {
return when (this) {
RepetitionTimeUnit.MINUTES -> Calendar.MINUTE
RepetitionTimeUnit.HOURS -> Calendar.HOUR
RepetitionTimeUnit.DAYS -> Calendar.DAY_OF_MONTH
RepetitionTimeUnit.WEEKS -> Calendar.WEEK_OF_YEAR
RepetitionTimeUnit.MONTHS -> Calendar.MONTH
RepetitionTimeUnit.YEARS -> Calendar.YEAR
}
}
fun Reminder.nextNotification(from: Date = Date()): Date? {
if (from.before(dateTime)) {
return dateTime
}
if (repetition == null) {
return null
}
val timeDifferenceMillis: Long = from.time - dateTime.time
val intervalsPassed = timeDifferenceMillis / repetition!!.toMillis()
val unitsUntilNext = ((repetition!!.value) * (intervalsPassed + 1)).toInt()
val reminderStart = dateTime.toCalendar()
reminderStart.add(repetition!!.unit.toCalendarField(), unitsUntilNext)
return reminderStart.time
}
fun Reminder.nextRepetition(from: Date = Date()): Date? {
if (repetition == null) {
return null
}
return nextNotification(from)
}
fun Reminder.hasUpcomingNotification() = !(dateTime.before(Date()) && repetition == null)
fun Repetition.toMillis(): Long {
return Calendar.getInstance()
.apply {
timeInMillis = 0
add(unit.toCalendarField(), value)
}
.timeInMillis
}
fun Collection<Reminder>.hasAnyUpcomingNotifications(): Boolean {
return any { it.hasUpcomingNotification() }
}
fun Collection<Reminder>.findNextNotificationDate(): Date? {
return mapNotNull { it.nextNotification() }.minByOrNull { it }
}
fun Date.toCalendar() = Calendar.getInstance().apply { timeInMillis = this@toCalendar.time }
fun List<ListItem>.toText() = buildString {
for (item in this@toText) {
val check = if (item.checked) "[✓]" else "[ ]"
val childIndentation = if (item.isChild) " " else ""
appendLine("$childIndentation$check ${item.body}")
}
}
fun Collection<ListItem>.deepCopy() = map { it.copy(children = mutableListOf()) }
fun ColorString.isValid() =
when (this) {
COLOR_DEFAULT -> true
else ->
try {
android.graphics.Color.parseColor(this)
true
} catch (e: Exception) {
false
}
}

View file

@ -1,18 +0,0 @@
package com.philkes.notallyx.data.model
import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.viewmodel.preference.StaticTextProvider
enum class NoteViewMode(override val textResId: Int) : StaticTextProvider {
READ_ONLY(R.string.read_only),
EDIT(R.string.edit);
companion object {
fun valueOfOrDefault(value: String) =
try {
NoteViewMode.valueOf(value)
} catch (e: Exception) {
EDIT
}
}
}

View file

@ -1,19 +0,0 @@
package com.philkes.notallyx.data.model
import android.os.Parcelable
import java.util.Date
import kotlinx.parcelize.Parcelize
@Parcelize
data class Reminder(var id: Long, var dateTime: Date, var repetition: Repetition?) : Parcelable
@Parcelize data class Repetition(var value: Int, var unit: RepetitionTimeUnit) : Parcelable
enum class RepetitionTimeUnit {
MINUTES,
HOURS,
DAYS,
WEEKS,
MONTHS,
YEARS,
}

View file

@ -21,16 +21,15 @@ class SearchResult(
value = emptyList()
}
fun fetch(keyword: String, folder: Folder, label: String?) {
fun fetch(keyword: String, folder: Folder) {
job?.cancel()
liveData?.removeObserver(observer)
job =
scope.launch {
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
// if (keyword.isNotEmpty())
// baseNoteDao.getBaseNotesByKeyword(keyword, folder, label)
// else baseNoteDao.getFrom(folder)
liveData?.observeForever(observer)
if (keyword.isNotEmpty()) {
liveData = baseNoteDao.getBaseNotesByKeyword(keyword, folder)
liveData?.observeForever(observer)
} else value = emptyList()
}
}
}

View file

@ -1,14 +1,14 @@
package com.philkes.notallyx.data.model
data class SpanRepresentation(
var bold: Boolean,
var link: Boolean,
var linkData: String?,
var italic: Boolean,
var monospace: Boolean,
var strikethrough: Boolean,
var start: Int,
var end: Int,
var bold: Boolean = false,
var link: Boolean = false,
var linkData: String? = null,
var italic: Boolean = false,
var monospace: Boolean = false,
var strikethrough: Boolean = false,
) {
fun isNotUseless(): Boolean {

View file

@ -2,14 +2,5 @@ package com.philkes.notallyx.data.model
enum class Type {
NOTE,
LIST;
companion object {
fun valueOfOrDefault(value: String) =
try {
Type.valueOf(value)
} catch (e: Exception) {
NOTE
}
}
LIST,
}

View file

@ -3,13 +3,30 @@ package com.philkes.notallyx.presentation.activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.data.model.Header
import com.philkes.notallyx.databinding.ActivityConfigureWidgetBinding
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.misc.View
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.IO
import java.util.Collections
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConfigureWidgetActivity : PickNoteActivity() {
class ConfigureWidgetActivity : LockedActivity<ActivityConfigureWidgetBinding>(), ListItemListener {
private lateinit var adapter: BaseNoteAdapter
private val id by lazy {
intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
@ -19,20 +36,63 @@ class ConfigureWidgetActivity : PickNoteActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityConfigureWidgetBinding.inflate(layoutInflater)
setContentView(binding.root)
val result = Intent()
result.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
setResult(RESULT_CANCELED, result)
val preferences = Preferences.getInstance(application)
adapter =
with(preferences) {
BaseNoteAdapter(
Collections.emptySet(),
dateFormat.value,
textSize.value,
maxItems,
maxLines,
maxTitle,
IO.getExternalImagesDirectory(application),
this@ConfigureWidgetActivity,
)
}
binding.RecyclerView.apply {
adapter = this@ConfigureWidgetActivity.adapter
setHasFixedSize(true)
layoutManager =
if (preferences.view.value == View.grid) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(this@ConfigureWidgetActivity)
}
val database = NotallyDatabase.getDatabase(application)
val pinned = Header(getString(R.string.pinned))
val others = Header(getString(R.string.others))
database.observe(this) {
lifecycleScope.launch {
val notes =
withContext(Dispatchers.IO) {
val raw = it.getBaseNoteDao().getAllNotes()
BaseNoteModel.transform(raw, pinned, others)
}
adapter.submitList(notes)
}
}
}
override fun onClick(position: Int) {
if (position != -1) {
val preferences = NotallyXPreferences.getInstance(application)
val baseNote = adapter.getItem(position) as BaseNote
preferences.updateWidget(id, baseNote.id, baseNote.type)
val preferences = Preferences.getInstance(application)
val noteId = (adapter.getItem(position) as BaseNote).id
preferences.updateWidget(id, noteId)
val manager = AppWidgetManager.getInstance(this)
WidgetProvider.updateWidget(application, manager, id, baseNote.id, baseNote.type)
WidgetProvider.updateWidget(this, manager, id, noteId)
val success = Intent()
success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
@ -40,4 +100,6 @@ class ConfigureWidgetActivity : PickNoteActivity() {
finish()
}
}
override fun onLongClick(position: Int) {}
}

View file

@ -3,55 +3,33 @@ package com.philkes.notallyx.presentation.activity
import android.app.Activity
import android.app.KeyguardManager
import android.content.Intent
import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_HW_NOT_PRESENT
import android.hardware.biometrics.BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.R
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
private lateinit var notallyXApplication: NotallyXApplication
private lateinit var biometricAuthenticationActivityResultLauncher:
ActivityResultLauncher<Intent>
protected lateinit var binding: T
protected lateinit var preferences: NotallyXPreferences
val baseModel: BaseNoteModel by viewModels()
protected lateinit var preferences: Preferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
notallyXApplication = (application as NotallyXApplication)
preferences = NotallyXPreferences.getInstance(application)
biometricAuthenticationActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
unlock()
} else {
finish()
}
}
preferences = Preferences.getInstance(application)
}
override fun onResume() {
if (preferences.biometricLock.value == BiometricLock.ENABLED) {
if (preferences.biometricLock.value == enabled) {
if (hasToAuthenticateWithBiometric()) {
hide()
showLockScreen()
@ -64,64 +42,34 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
override fun onPause() {
super.onPause()
if (
preferences.biometricLock.value == BiometricLock.ENABLED &&
notallyXApplication.locked.value
) {
if (preferences.biometricLock.value == enabled) {
hide()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_BIOMETRIC_AUTHENTICATION && resultCode == Activity.RESULT_OK) {
notallyXApplication.isLocked = false
show()
}
}
open fun showLockScreen() {
showBiometricOrPinPrompt(
true,
preferences.iv.value!!,
biometricAuthenticationActivityResultLauncher,
preferences.iv!!,
REQUEST_BIOMETRIC_AUTHENTICATION,
R.string.unlock,
onSuccess = { unlock() },
) { errorCode ->
when (errorCode) {
BIOMETRIC_ERROR_NO_BIOMETRICS -> {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.unlock_with_biometrics_not_setup)
.setPositiveButton(R.string.disable) { _, _ ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseModel.disableBiometricLock()
}
show()
}
.setNegativeButton(R.string.tap_to_set_up) { _, _ ->
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
startActivity(intent)
}
.show()
}
BIOMETRIC_ERROR_HW_NOT_PRESENT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
baseModel.disableBiometricLock()
showToast(R.string.biometrics_disable_success)
}
show()
}
else -> finish()
}
onSuccess = {
notallyXApplication.isLocked = false
show()
},
) {
finish()
}
}
private fun unlock() {
notallyXApplication.locked.value = false
show()
}
protected fun show() {
binding.root.visibility = VISIBLE
}
@ -131,12 +79,16 @@ abstract class LockedActivity<T : ViewBinding> : AppCompatActivity() {
}
private fun hasToAuthenticateWithBiometric(): Boolean {
return ContextCompat.getSystemService(this, KeyguardManager::class.java)?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
(it.isDeviceLocked || notallyXApplication.locked.value)
} else {
false
}
} ?: false
val keyguardManager: KeyguardManager =
this.getSystemService(KEYGUARD_SERVICE) as KeyguardManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
(keyguardManager.isDeviceLocked || notallyXApplication.isLocked)
} else {
false
}
}
companion object {
private const val REQUEST_BIOMETRIC_AUTHENTICATION = 11
}
}

View file

@ -1,24 +1,26 @@
package com.philkes.notallyx.presentation.activity.main
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.print.PostPDFGenerator
import android.transition.TransitionManager
import android.view.Menu
import android.view.Menu.CATEGORY_CONTAINER
import android.view.Menu.CATEGORY_SYSTEM
import android.view.MenuItem
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.core.view.GravityCompat
import androidx.core.view.children
import androidx.core.view.forEach
import androidx.core.widget.doAfterTextChanged
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.navOptions
import androidx.navigation.ui.AppBarConfiguration
@ -28,54 +30,40 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.platform.MaterialFade
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.databinding.ActivityMainBinding
import com.philkes.notallyx.databinding.DialogColorBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.activity.main.fragment.NotallyFragment
import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.movedToResId
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.TriStateCheckBox
import com.philkes.notallyx.presentation.view.misc.tristatecheckbox.setMultiChoiceTriStateItems
import com.philkes.notallyx.presentation.view.main.ColorAdapter
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_EMPTY
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel.Companion.CURRENT_LABEL_NONE
import com.philkes.notallyx.presentation.viewmodel.ExportMimeType
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.utils.backup.exportNotes
import com.philkes.notallyx.utils.shareNote
import com.philkes.notallyx.utils.showColorSelectDialog
import kotlinx.coroutines.Dispatchers
import com.philkes.notallyx.utils.Operations
import com.philkes.notallyx.utils.add
import com.philkes.notallyx.utils.applySpans
import com.philkes.notallyx.utils.movedToResId
import java.io.File
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : LockedActivity<ActivityMainBinding>() {
private lateinit var navController: NavController
private lateinit var configuration: AppBarConfiguration
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportNotesActivityResultLauncher: ActivityResultLauncher<Intent>
private var isStartViewFragment = false
private val actionModeCancelCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
baseModel.actionMode.close(true)
}
}
private val model: BaseNoteModel by viewModels()
var getCurrentFragmentNotes: (() -> Collection<BaseNote>?)? = null
override fun onBackPressed() {
if (model.actionMode.enabled.value) {
model.actionMode.close(true)
} else super.onBackPressed()
}
override fun onSupportNavigateUp(): Boolean {
baseModel.keyword = ""
return navController.navigateUp(configuration)
}
@ -88,183 +76,48 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
setupMenu()
setupActionMode()
setupNavigation()
setupActivityResultLaunchers()
val fragmentIdToLoad = intent.getIntExtra(EXTRA_FRAGMENT_TO_OPEN, -1)
if (fragmentIdToLoad != -1) {
navController.navigate(fragmentIdToLoad, intent.extras)
} else if (savedInstanceState == null) {
navigateToStartView()
}
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (baseModel.actionMode.enabled.value) {
return
}
if (
!isStartViewFragment &&
!intent.getBooleanExtra(EXTRA_SKIP_START_VIEW_ON_BACK, false)
) {
navigateToStartView()
} else {
finish()
}
}
},
)
onBackPressedDispatcher.addCallback(this, actionModeCancelCallback)
setupSearch()
}
private fun getStartViewNavigation(): Pair<Int, Bundle> {
return when (val startView = preferences.startView.value) {
START_VIEW_DEFAULT -> Pair(R.id.Notes, Bundle())
START_VIEW_UNLABELED -> Pair(R.id.Unlabeled, Bundle())
else -> {
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, startView) }
Pair(R.id.DisplayLabel, bundle)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_EXPORT_FILE && resultCode == Activity.RESULT_OK) {
data?.data?.let { uri -> model.writeCurrentFileToUri(uri) }
}
}
private fun navigateToStartView() {
val (id, bundle) = getStartViewNavigation()
navController.navigate(id, bundle)
}
private fun setupFAB() {
binding.TakeNote.setOnClickListener {
val intent = Intent(this, EditNoteActivity::class.java)
startActivity(prepareNewNoteIntent(intent))
startActivity(intent)
}
binding.MakeList.setOnClickListener {
val intent = Intent(this, EditListActivity::class.java)
startActivity(prepareNewNoteIntent(intent))
startActivity(intent)
}
}
private fun prepareNewNoteIntent(intent: Intent): Intent {
return supportFragmentManager
.findFragmentById(R.id.NavHostFragment)
?.childFragmentManager
?.fragments
?.firstOrNull()
?.let { fragment ->
return if (fragment is NotallyFragment) {
fragment.prepareNewNoteIntent(intent)
} else intent
} ?: intent
}
private var labelsMenuItems: List<MenuItem> = listOf()
private var labelsMoreMenuItem: MenuItem? = null
private var labels: List<String> = listOf()
private var labelsLiveData: LiveData<List<String>>? = null
private fun setupMenu() {
binding.NavigationView.menu.apply {
add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home)
addStaticLabelsMenuItems()
NotallyDatabase.getDatabase(application).observe(this@MainActivity) { database ->
labelsLiveData?.removeObservers(this@MainActivity)
labelsLiveData =
database.getLabelDao().getAll().also {
it.observe(this@MainActivity) { labels ->
this@MainActivity.labels = labels
setupLabelsMenuItems(labels, preferences.maxLabels.value)
}
}
}
add(2, R.id.Deleted, CATEGORY_SYSTEM + 1, R.string.deleted)
.setCheckable(true)
.setIcon(R.drawable.delete)
add(2, R.id.Archived, CATEGORY_SYSTEM + 2, R.string.archived)
add(1, R.id.Labels, 0, R.string.labels).setCheckable(true).setIcon(R.drawable.label)
add(2, R.id.Deleted, 0, R.string.deleted).setCheckable(true).setIcon(R.drawable.delete)
add(2, R.id.Archived, 0, R.string.archived)
.setCheckable(true)
.setIcon(R.drawable.archive)
add(3, R.id.Reminders, CATEGORY_SYSTEM + 3, R.string.reminders)
.setCheckable(true)
.setIcon(R.drawable.notifications)
add(3, R.id.Settings, CATEGORY_SYSTEM + 4, R.string.settings)
add(3, R.id.Settings, 0, R.string.settings)
.setCheckable(true)
.setIcon(R.drawable.settings)
}
baseModel.preferences.labelsHidden.observe(this) { hiddenLabels ->
hideLabelsInNavigation(hiddenLabels, baseModel.preferences.maxLabels.value)
}
baseModel.preferences.maxLabels.observe(this) { maxLabels ->
binding.NavigationView.menu.setupLabelsMenuItems(labels, maxLabels)
}
}
private fun Menu.addStaticLabelsMenuItems() {
add(1, R.id.Unlabeled, CATEGORY_CONTAINER + 1, R.string.unlabeled)
.setCheckable(true)
.setChecked(baseModel.currentLabel == CURRENT_LABEL_NONE)
.setIcon(R.drawable.label_off)
add(1, R.id.Labels, CATEGORY_CONTAINER + 2, R.string.labels)
.setCheckable(true)
.setIcon(R.drawable.label_more)
}
private fun Menu.setupLabelsMenuItems(labels: List<String>, maxLabelsToDisplay: Int) {
removeGroup(1)
addStaticLabelsMenuItems()
labelsMenuItems =
labels
.mapIndexed { index, label ->
add(1, R.id.DisplayLabel, CATEGORY_CONTAINER + index + 3, label)
.setCheckable(true)
.setChecked(baseModel.currentLabel == label)
.setVisible(index < maxLabelsToDisplay)
.setIcon(R.drawable.label)
.setOnMenuItemClickListener {
navigateToLabel(label)
false
}
}
.toList()
labelsMoreMenuItem =
if (labelsMenuItems.size > maxLabelsToDisplay) {
add(
1,
R.id.Labels,
CATEGORY_CONTAINER + labelsMenuItems.size + 2,
getString(R.string.more, labelsMenuItems.size - maxLabelsToDisplay),
)
.setCheckable(true)
.setIcon(R.drawable.label)
} else null
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
setupActionBarWithNavController(navController, configuration)
hideLabelsInNavigation(baseModel.preferences.labelsHidden.value, maxLabelsToDisplay)
}
private fun navigateToLabel(label: String) {
val bundle = Bundle().apply { putString(EXTRA_DISPLAYED_LABEL, label) }
navController.navigate(R.id.DisplayLabel, bundle)
}
private fun hideLabelsInNavigation(hiddenLabels: Set<String>, maxLabelsToDisplay: Int) {
var visibleLabels = 0
labelsMenuItems.forEach { menuItem ->
val visible =
!hiddenLabels.contains(menuItem.title) && visibleLabels < maxLabelsToDisplay
menuItem.setVisible(visible)
if (visible) {
visibleLabels++
}
}
labelsMoreMenuItem?.setTitle(getString(R.string.more, labels.size - visibleLabels))
try {
val pInfo = packageManager.getPackageInfo(packageName, 0)
val version = pInfo.versionName
binding.Version.text = "v$version"
} catch (_: PackageManager.NameNotFoundException) {}
}
private fun setupActionMode() {
binding.ActionMode.setNavigationOnClickListener { baseModel.actionMode.close(true) }
binding.ActionMode.setNavigationOnClickListener { model.actionMode.close(true) }
val transition =
MaterialFade().apply {
@ -276,7 +129,7 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
excludeTarget(binding.NavigationView, true)
}
baseModel.actionMode.enabled.observe(this) { enabled ->
model.actionMode.enabled.observe(this) { enabled ->
TransitionManager.beginDelayedTransition(binding.RelativeLayout, transition)
if (enabled) {
binding.Toolbar.visibility = View.GONE
@ -287,126 +140,257 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
binding.ActionMode.visibility = View.GONE
binding.DrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
}
actionModeCancelCallback.isEnabled = enabled
}
val menu = binding.ActionMode.menu
baseModel.folder.observe(this@MainActivity, ModelFolderObserver(menu, baseModel))
baseModel.actionMode.loading.observe(this@MainActivity) { loading ->
menu.setGroupEnabled(Menu.NONE, !loading)
val pinned = menu.add(R.string.pin, R.drawable.pin) {}
val share = menu.add(R.string.share, R.drawable.share) { share() }
val labels = menu.add(R.string.labels, R.drawable.label) { label() }
val export = createExportMenu(menu)
val changeColor = menu.add(R.string.change_color, R.drawable.change_color) { changeColor() }
val delete = menu.add(R.string.delete, R.drawable.delete) { moveNotes(Folder.DELETED) }
val archive = menu.add(R.string.archive, R.drawable.archive) { moveNotes(Folder.ARCHIVED) }
val restore = menu.add(R.string.restore, R.drawable.restore) { moveNotes(Folder.NOTES) }
val unarchive =
menu.add(R.string.unarchive, R.drawable.unarchive) { moveNotes(Folder.NOTES) }
val deleteForever = menu.add(R.string.delete_forever, R.drawable.delete) { deleteForever() }
model.actionMode.count.observe(this) { count ->
if (count == 0) {
menu.forEach { item -> item.setVisible(false) }
} else {
binding.ActionMode.title = count.toString()
val baseNote = model.actionMode.getFirstNote()
if (count == 1) {
if (baseNote.pinned) {
pinned.setTitle(R.string.unpin)
pinned.setIcon(R.drawable.unpin)
} else {
pinned.setTitle(R.string.pin)
pinned.setIcon(R.drawable.pin)
}
pinned.onClick { model.pinBaseNote(!baseNote.pinned) }
}
pinned.setVisible(count == 1)
share.setVisible(count == 1)
labels.setVisible(count == 1)
export.setVisible(count == 1)
changeColor.setVisible(true)
val folder = baseNote.folder
delete.setVisible(folder == Folder.NOTES || folder == Folder.ARCHIVED)
archive.setVisible(folder == Folder.NOTES)
restore.setVisible(folder == Folder.DELETED)
unarchive.setVisible(folder == Folder.ARCHIVED)
deleteForever.setVisible(folder == Folder.DELETED)
}
}
}
private fun createExportMenu(menu: Menu): MenuItem {
return menu
.addSubMenu(R.string.export)
.apply {
setIcon(R.drawable.export)
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
add("PDF").onClick { exportToPDF() }
add("TXT").onClick { exportToTXT() }
add("JSON").onClick { exportToJSON() }
add("HTML").onClick { exportToHTML() }
}
.item
}
fun MenuItem.onClick(function: () -> Unit) {
setOnMenuItemClickListener {
function()
return@setOnMenuItemClickListener false
}
}
private fun moveNotes(folderTo: Folder) {
if (baseModel.actionMode.loading.value || baseModel.actionMode.isEmpty()) {
return
}
try {
baseModel.actionMode.loading.value = true
val folderFrom = baseModel.actionMode.getFirstNote().folder
val ids = baseModel.moveBaseNotes(folderTo)
Snackbar.make(
findViewById(R.id.DrawerLayout),
getQuantityString(folderTo.movedToResId(), ids.size),
Snackbar.LENGTH_SHORT,
)
.apply { setAction(R.string.undo) { baseModel.moveBaseNotes(ids, folderFrom) } }
.show()
} finally {
baseModel.actionMode.loading.value = false
}
val folderFrom = model.actionMode.getFirstNote().folder
val ids = model.moveBaseNotes(folderTo)
Snackbar.make(
findViewById(R.id.DrawerLayout),
resources.getQuantityString(folderTo.movedToResId(), ids.size, ids.size),
Snackbar.LENGTH_SHORT,
)
.apply { setAction(R.string.undo) { model.moveBaseNotes(ids, folderFrom) } }
.show()
}
private fun share() {
val baseNote = baseModel.actionMode.getFirstNote()
this.shareNote(baseNote)
val baseNote = model.actionMode.getFirstNote()
val body =
when (baseNote.type) {
Type.NOTE -> baseNote.body.applySpans(baseNote.spans)
Type.LIST -> Operations.getBody(baseNote.items)
}
Operations.shareNote(this, baseNote.title, body)
}
private fun changeColor() {
val dialog = MaterialAlertDialogBuilder(this).setTitle(R.string.change_color).create()
val colorAdapter =
ColorAdapter(
object : ListItemListener {
override fun onClick(position: Int) {
dialog.dismiss()
val color = Color.entries[position]
model.colorBaseNote(color)
}
override fun onLongClick(position: Int) {}
}
)
DialogColorBinding.inflate(layoutInflater).apply {
RecyclerView.adapter = colorAdapter
dialog.setView(root)
dialog.show()
}
}
private fun deleteForever() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_selected_notes)
.setPositiveButton(R.string.delete) { _, _ -> baseModel.deleteSelectedBaseNotes() }
.setCancelButton()
.setPositiveButton(R.string.delete) { _, _ -> model.deleteBaseNotes() }
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun label() {
val baseNotes = baseModel.actionMode.selectedNotes.values
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val labels = baseModel.getAllLabels()
val labels = model.getAllLabels()
if (labels.isNotEmpty()) {
displaySelectLabelsDialog(labels, baseNotes)
displaySelectLabelsDialog(labels, baseNote)
} else {
baseModel.actionMode.close(true)
model.actionMode.close(true)
navigateWithAnimation(R.id.Labels)
}
}
}
private fun displaySelectLabelsDialog(labels: Array<String>, baseNotes: Collection<BaseNote>) {
private fun displaySelectLabelsDialog(labels: Array<String>, baseNote: BaseNote) {
val checkedPositions =
labels
.map { label ->
if (baseNotes.all { it.labels.contains(label) }) {
TriStateCheckBox.State.CHECKED
} else if (baseNotes.any { it.labels.contains(label) }) {
TriStateCheckBox.State.PARTIALLY_CHECKED
} else {
TriStateCheckBox.State.UNCHECKED
}
}
.toTypedArray()
BooleanArray(labels.size) { index -> baseNote.labels.contains(labels[index]) }
MaterialAlertDialogBuilder(this)
.setTitle(R.string.labels)
.setCancelButton()
.setMultiChoiceTriStateItems(this, labels, checkedPositions) { idx, state ->
checkedPositions[idx] = state
.setNegativeButton(R.string.cancel, null)
.setMultiChoiceItems(labels, checkedPositions) { _, which, isChecked ->
checkedPositions[which] = isChecked
}
.setPositiveButton(R.string.save) { _, _ ->
val checkedLabels =
checkedPositions.mapIndexedNotNull { index, checked ->
if (checked == TriStateCheckBox.State.CHECKED) {
labels[index]
} else null
val new = ArrayList<String>()
checkedPositions.forEachIndexed { index, checked ->
if (checked) {
val label = labels[index]
new.add(label)
}
val uncheckedLabels =
checkedPositions.mapIndexedNotNull { index, checked ->
if (checked == TriStateCheckBox.State.UNCHECKED) {
labels[index]
} else null
}
val updatedBaseNotesLabels =
baseNotes.map { baseNote ->
val noteLabels = baseNote.labels.toMutableList()
checkedLabels.forEach { checkedLabel ->
if (!noteLabels.contains(checkedLabel)) {
noteLabels.add(checkedLabel)
}
}
uncheckedLabels.forEach { uncheckedLabel ->
if (noteLabels.contains(uncheckedLabel)) {
noteLabels.remove(uncheckedLabel)
}
}
noteLabels
}
baseNotes.zip(updatedBaseNotesLabels).forEach { (baseNote, updatedLabels) ->
baseModel.updateBaseNoteLabels(updatedLabels, baseNote.id)
}
model.updateBaseNoteLabels(new, baseNote.id)
}
.show()
}
private fun exportSelectedNotes(mimeType: ExportMimeType) {
exportNotes(
mimeType,
baseModel.actionMode.selectedNotes.values,
exportFileActivityResultLauncher,
exportNotesActivityResultLauncher,
private fun exportToPDF() {
val baseNote = model.actionMode.getFirstNote()
model.getPDFFile(
baseNote,
object : PostPDFGenerator.OnResult {
override fun onSuccess(file: File) {
showFileOptionsDialog(file, "application/pdf")
}
override fun onFailure(message: CharSequence?) {
Toast.makeText(
this@MainActivity,
R.string.something_went_wrong,
Toast.LENGTH_SHORT,
)
.show()
}
},
)
}
private fun exportToTXT() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val file = model.getTXTFile(baseNote)
showFileOptionsDialog(file, "text/plain")
}
}
private fun exportToJSON() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val file = model.getJSONFile(baseNote)
showFileOptionsDialog(file, "application/json")
}
}
private fun exportToHTML() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val file = model.getHTMLFile(baseNote)
showFileOptionsDialog(file, "text/html")
}
}
private fun showFileOptionsDialog(file: File, mimeType: String) {
val uri = FileProvider.getUriForFile(this, "${packageName}.provider", file)
MenuDialog(this)
.add(R.string.share) { shareFile(uri, mimeType) }
.add(R.string.view_file) { viewFile(uri, mimeType) }
.add(R.string.save_to_device) { saveFileToDevice(file, mimeType) }
.show()
}
private fun viewFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
val chooser = Intent.createChooser(intent, getString(R.string.view_note))
startActivity(chooser)
}
private fun shareFile(uri: Uri, mimeType: String) {
val intent =
Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
}
val chooser = Intent.createChooser(intent, null)
startActivity(chooser)
}
private fun saveFileToDevice(file: File, mimeType: String) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, file.nameWithoutExtension)
}
model.currentFile = file
startActivityForResult(intent, REQUEST_EXPORT_FILE)
}
private fun setupNavigation() {
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.NavHostFragment) as NavHostFragment
@ -435,47 +419,35 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
}
)
navController.addOnDestinationChangedListener { _, destination, bundle ->
navController.addOnDestinationChangedListener { _, destination, _ ->
fragmentIdToLoad = destination.id
when (fragmentIdToLoad) {
R.id.DisplayLabel ->
bundle?.getString(EXTRA_DISPLAYED_LABEL)?.let {
baseModel.currentLabel = it
binding.NavigationView.menu.children
.find { menuItem -> menuItem.title == it }
?.let { menuItem -> menuItem.isChecked = true }
}
R.id.Unlabeled -> {
baseModel.currentLabel = CURRENT_LABEL_NONE
binding.NavigationView.setCheckedItem(destination.id)
}
else -> {
baseModel.currentLabel = CURRENT_LABEL_EMPTY
binding.NavigationView.setCheckedItem(destination.id)
}
}
when (destination.id) {
R.id.Notes,
R.id.DisplayLabel,
R.id.Unlabeled -> {
binding.TakeNote.show()
binding.MakeList.show()
}
else -> {
binding.TakeNote.hide()
binding.MakeList.hide()
}
}
isStartViewFragment = isStartViewFragment(destination.id, bundle)
binding.NavigationView.setCheckedItem(destination.id)
handleDestinationChange(destination)
}
}
private fun isStartViewFragment(id: Int, bundle: Bundle?): Boolean {
val (startViewId, startViewBundle) = getStartViewNavigation()
return startViewId == id &&
startViewBundle.getString(EXTRA_DISPLAYED_LABEL) ==
bundle?.getString(EXTRA_DISPLAYED_LABEL)
private fun handleDestinationChange(destination: NavDestination) {
if (destination.id == R.id.Notes) {
binding.TakeNote.show()
binding.MakeList.show()
} else {
binding.TakeNote.hide()
binding.MakeList.hide()
}
val inputManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
if (destination.id == R.id.Search) {
binding.EnterSearchKeyword.apply {
visibility = View.VISIBLE
requestFocus()
inputManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
} else {
binding.EnterSearchKeyword.apply {
visibility = View.GONE
inputManager.hideSoftInputFromWindow(this.windowToken, 0)
}
}
}
private fun navigateWithAnimation(id: Int) {
@ -492,196 +464,14 @@ class MainActivity : LockedActivity<ActivityMainBinding>() {
navController.navigate(id, null, options)
}
private fun setupActivityResultLaunchers() {
exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> baseModel.exportSelectedFileToUri(uri) }
}
}
exportNotesActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> baseModel.exportSelectedNotesToFolder(uri) }
}
}
}
private inner class ModelFolderObserver(
private val menu: Menu,
private val model: BaseNoteModel,
) : Observer<Folder> {
override fun onChanged(value: Folder) {
menu.clear()
model.actionMode.count.removeObservers(this@MainActivity)
menu.add(
R.string.select_all,
R.drawable.select_all,
showAsAction = MenuItem.SHOW_AS_ACTION_ALWAYS,
) {
getCurrentFragmentNotes?.invoke()?.let { model.actionMode.add(it) }
}
when (value) {
Folder.NOTES -> {
val pinned = menu.addPinned(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.addLabels(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.addDelete(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.add(R.string.archive, R.drawable.archive) { moveNotes(Folder.ARCHIVED) }
menu.addChangeColor()
val share = menu.addShare()
menu.addExportMenu()
model.actionMode.count.observeCountAndPinned(this@MainActivity, share, pinned)
}
Folder.ARCHIVED -> {
menu.add(
R.string.unarchive,
R.drawable.unarchive,
MenuItem.SHOW_AS_ACTION_ALWAYS,
) {
moveNotes(Folder.NOTES)
}
menu.addDelete(MenuItem.SHOW_AS_ACTION_ALWAYS)
menu.addExportMenu(MenuItem.SHOW_AS_ACTION_ALWAYS)
val pinned = menu.addPinned()
menu.addLabels()
menu.addChangeColor()
val share = menu.addShare()
model.actionMode.count.observeCountAndPinned(this@MainActivity, share, pinned)
}
Folder.DELETED -> {
menu.add(R.string.restore, R.drawable.restore, MenuItem.SHOW_AS_ACTION_ALWAYS) {
moveNotes(Folder.NOTES)
}
menu.add(
R.string.delete_forever,
R.drawable.delete,
MenuItem.SHOW_AS_ACTION_ALWAYS,
) {
deleteForever()
}
menu.addExportMenu()
menu.addChangeColor()
val share = menu.add(R.string.share, R.drawable.share) { share() }
model.actionMode.count.observeCount(this@MainActivity, share)
}
}
}
private fun Menu.addPinned(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.pin, R.drawable.pin, showAsAction) {}
}
private fun Menu.addLabels(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.labels, R.drawable.label, showAsAction) { label() }
}
private fun Menu.addChangeColor(
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
): MenuItem {
return add(R.string.change_color, R.drawable.change_color, showAsAction) {
lifecycleScope.launch {
val colors =
withContext(Dispatchers.IO) {
NotallyDatabase.getDatabase(
this@MainActivity,
observePreferences = false,
)
.value
.getBaseNoteDao()
.getAllColors()
}
// Show color as selected only if all selected notes have the same color
val currentColor =
model.actionMode.selectedNotes.values
.map { it.color }
.distinct()
.takeIf { it.size == 1 }
?.firstOrNull()
showColorSelectDialog(
colors,
currentColor,
null,
{ selectedColor, oldColor ->
if (oldColor != null) {
model.changeColor(oldColor, selectedColor)
}
model.colorBaseNote(selectedColor)
},
) { colorToDelete, newColor ->
model.changeColor(colorToDelete, newColor)
}
}
}
}
private fun Menu.addDelete(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.delete, R.drawable.delete, showAsAction) {
moveNotes(Folder.DELETED)
}
}
private fun Menu.addShare(showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM): MenuItem {
return add(R.string.share, R.drawable.share, showAsAction) { share() }
}
private fun Menu.addExportMenu(
showAsAction: Int = MenuItem.SHOW_AS_ACTION_IF_ROOM
): MenuItem {
return addSubMenu(R.string.export)
.apply {
setIcon(R.drawable.export)
item.setShowAsAction(showAsAction)
ExportMimeType.entries.forEach {
add(it.name).onClick { exportSelectedNotes(it) }
}
}
.item
}
fun MenuItem.onClick(function: () -> Unit) {
setOnMenuItemClickListener {
function()
return@setOnMenuItemClickListener false
}
}
private fun NotNullLiveData<Int>.observeCount(
lifecycleOwner: LifecycleOwner,
share: MenuItem,
onCountChange: ((Int) -> Unit)? = null,
) {
observe(lifecycleOwner) { count ->
binding.ActionMode.title = count.toString()
onCountChange?.invoke(count)
share.setVisible(count == 1)
}
}
private fun NotNullLiveData<Int>.observeCountAndPinned(
lifecycleOwner: LifecycleOwner,
share: MenuItem,
pinned: MenuItem,
) {
observeCount(lifecycleOwner, share) {
val baseNotes = model.actionMode.selectedNotes.values
if (baseNotes.any { !it.pinned }) {
pinned.setTitle(R.string.pin).setIcon(R.drawable.pin).onClick {
model.pinBaseNotes(true)
}
} else {
pinned.setTitle(R.string.unpin).setIcon(R.drawable.unpin).onClick {
model.pinBaseNotes(false)
}
}
}
private fun setupSearch() {
binding.EnterSearchKeyword.apply {
setText(model.keyword)
doAfterTextChanged { text -> model.keyword = requireNotNull(text).trim().toString() }
}
}
companion object {
const val EXTRA_FRAGMENT_TO_OPEN = "notallyx.intent.extra.FRAGMENT_TO_OPEN"
const val EXTRA_SKIP_START_VIEW_ON_BACK = "notallyx.intent.extra.SKIP_START_VIEW_ON_BACK"
private const val REQUEST_EXPORT_FILE = 10
}
}

View file

@ -1,17 +1,9 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.View
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
class ArchivedFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.ARCHIVED
}
override fun getBackground() = R.drawable.archive
override fun getObservable() = model.archivedNotes!!

View file

@ -1,22 +1,13 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.utils.add
class DeletedFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.DELETED
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(R.string.delete_all, R.drawable.delete_all) { deleteAllNotes() }
}
@ -24,8 +15,8 @@ class DeletedFragment : NotallyFragment() {
private fun deleteAllNotes() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.delete_all_notes)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteAllTrashedBaseNotes() }
.setCancelButton()
.setPositiveButton(R.string.delete) { _, _ -> model.deleteAllBaseNotes() }
.setNegativeButton(R.string.cancel, null)
.show()
}

View file

@ -1,34 +1,16 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.presentation.view.Constants
class DisplayLabelFragment : NotallyFragment() {
private lateinit var label: String
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.NOTES
}
override fun getBackground() = R.drawable.label
override fun getObservable(): LiveData<List<Item>> {
label = requireNotNull(requireArguments().getString(EXTRA_DISPLAYED_LABEL))
val label = requireNotNull(requireArguments().getString(Constants.SelectedLabel))
return model.getNotesByLabel(label)
}
override fun prepareNewNoteIntent(intent: Intent): Intent {
return intent.putExtra(EXTRA_DISPLAYED_LABEL, label)
}
companion object {
const val EXTRA_DISPLAYED_LABEL = "notallyx.intent.extra.DISPLAYED_LABEL"
}
}

View file

@ -6,29 +6,27 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.databinding.DialogInputBinding
import com.philkes.notallyx.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.fragment.DisplayLabelFragment.Companion.EXTRA_DISPLAYED_LABEL
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.displayEditLabelDialog
import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.main.label.LabelAdapter
import com.philkes.notallyx.presentation.view.main.label.LabelData
import com.philkes.notallyx.presentation.view.main.label.LabelListener
import com.philkes.notallyx.presentation.view.Constants
import com.philkes.notallyx.presentation.view.main.LabelAdapter
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.add
class LabelsFragment : Fragment(), LabelListener {
class LabelsFragment : Fragment(), ListItemListener {
private var labelAdapter: LabelAdapter? = null
private var binding: FragmentNotesBinding? = null
@ -44,9 +42,13 @@ class LabelsFragment : Fragment(), LabelListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
labelAdapter = LabelAdapter(this)
binding?.MainListView?.apply {
initListView(requireContext())
binding?.RecyclerView?.apply {
setHasFixedSize(true)
adapter = labelAdapter
layoutManager = LinearLayoutManager(requireContext())
val itemDecoration = DividerItemDecoration(requireContext(), RecyclerView.VERTICAL)
addItemDecoration(itemDecoration)
setPadding(0, 0, 0, 0)
binding?.ImageView?.setImageResource(R.drawable.label)
}
@ -68,45 +70,25 @@ class LabelsFragment : Fragment(), LabelListener {
}
override fun onClick(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) ->
labelAdapter?.currentList?.get(position)?.let { value ->
val bundle = Bundle()
bundle.putString(EXTRA_DISPLAYED_LABEL, label)
bundle.putString(Constants.SelectedLabel, value)
findNavController().navigate(R.id.LabelsToDisplayLabel, bundle)
}
}
override fun onEdit(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) ->
displayEditLabelDialog(label, model)
}
}
override fun onDelete(position: Int) {
labelAdapter?.currentList?.get(position)?.let { (label, _) -> confirmDeletion(label) }
}
override fun onToggleVisibility(position: Int) {
override fun onLongClick(position: Int) {
labelAdapter?.currentList?.get(position)?.let { value ->
val hiddenLabels = model.preferences.labelsHidden.value.toMutableSet()
if (value.visibleInNavigation) {
hiddenLabels.add(value.label)
} else {
hiddenLabels.remove(value.label)
}
model.savePreference(model.preferences.labelsHidden, hiddenLabels)
val currentList = labelAdapter!!.currentList.toMutableList()
currentList[position] =
currentList[position].copy(visibleInNavigation = !value.visibleInNavigation)
labelAdapter!!.submitList(currentList)
MenuDialog(requireContext())
.add(R.string.edit) { displayEditLabelDialog(value) }
.add(R.string.delete) { confirmDeletion(value) }
.show()
}
}
private fun setupObserver() {
model.labels.observe(viewLifecycleOwner) { labels ->
val hiddenLabels = model.preferences.labelsHidden.value
val labelsData = labels.map { label -> LabelData(label, !hiddenLabels.contains(label)) }
labelAdapter?.submitList(labelsData)
labelAdapter?.submitList(labels)
binding?.ImageView?.isVisible = labels.isEmpty()
}
}
@ -118,7 +100,7 @@ class LabelsFragment : Fragment(), LabelListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.add_label)
.setView(dialogBinding.root)
.setCancelButton()
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
@ -126,18 +108,14 @@ class LabelsFragment : Fragment(), LabelListener {
model.insertLabel(label) { success: Boolean ->
if (success) {
dialog.dismiss()
} else {
showToast(R.string.label_exists)
}
} else
Toast.makeText(context, R.string.label_exists, Toast.LENGTH_LONG).show()
}
}
}
.showAndFocus(dialogBinding.EditText, allowFullSize = true) { positiveButton ->
dialogBinding.EditText.doAfterTextChanged { text ->
positiveButton.isEnabled = !text.isNullOrEmpty()
}
positiveButton.isEnabled = false
}
.show()
dialogBinding.EditText.requestFocus()
}
private fun confirmDeletion(value: String) {
@ -145,7 +123,37 @@ class LabelsFragment : Fragment(), LabelListener {
.setTitle(R.string.delete_label)
.setMessage(R.string.your_notes_associated)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteLabel(value) }
.setCancelButton()
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun displayEditLabelDialog(oldValue: String) {
val dialogBinding = DialogInputBinding.inflate(layoutInflater)
dialogBinding.EditText.setText(oldValue)
MaterialAlertDialogBuilder(requireContext())
.setView(dialogBinding.root)
.setTitle(R.string.edit_label)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
model.updateLabel(oldValue, value) { success ->
if (success) {
dialog.dismiss()
} else
Toast.makeText(
requireContext(),
R.string.label_exists,
Toast.LENGTH_LONG,
)
.show()
}
}
}
.show()
dialogBinding.EditText.requestFocus()
}
}

View file

@ -6,14 +6,10 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LiveData
import androidx.navigation.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
@ -24,31 +20,21 @@ import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.databinding.FragmentNotesBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_LABEL
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.FOLDER_FROM
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.FOLDER_TO
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.NOTE_ID
import com.philkes.notallyx.presentation.activity.note.EditListActivity
import com.philkes.notallyx.presentation.activity.note.EditNoteActivity
import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.movedToResId
import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.view.Constants
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.misc.View as ViewPref
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.NotesView
import com.philkes.notallyx.utils.movedToResId
abstract class NotallyFragment : Fragment(), ItemListener {
abstract class NotallyFragment : Fragment(), ListItemListener {
private var notesAdapter: BaseNoteAdapter? = null
private lateinit var openNoteActivityResultLauncher: ActivityResultLauncher<Intent>
private var lastSelectedNotePosition = -1
internal var binding: FragmentNotesBinding? = null
internal val model: BaseNoteModel by activityViewModels()
@ -59,67 +45,12 @@ abstract class NotallyFragment : Fragment(), ItemListener {
notesAdapter = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
if (layoutManager != null) {
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePosition != RecyclerView.NO_POSITION) {
val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition)
val offset = firstVisibleView?.top ?: 0
outState.putInt(EXTRA_SCROLL_POS, firstVisiblePosition)
outState.putInt(EXTRA_SCROLL_OFFSET, offset)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding?.ImageView?.setImageResource(getBackground())
setupAdapter()
setupRecyclerView()
setupObserver()
setupSearch()
setupActivityResultLaunchers()
savedInstanceState?.let { bundle ->
val scrollPosition = bundle.getInt(EXTRA_SCROLL_POS, -1)
val scrollOffset = bundle.getInt(EXTRA_SCROLL_OFFSET, 0)
if (scrollPosition > -1) {
binding?.MainListView?.post {
val layoutManager = binding?.MainListView?.layoutManager as? LinearLayoutManager
layoutManager?.scrollToPositionWithOffset(scrollPosition, scrollOffset)
}
}
}
}
private fun setupActivityResultLaunchers() {
openNoteActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
// If a note has been moved inside of EditActivity
// present snackbar to undo it
val data = result.data
val id = data?.getLongExtra(EXTRA_NOTE_ID, -1)
if (id != null) {
val folderFrom = Folder.valueOf(data.getStringExtra(EXTRA_FOLDER_FROM)!!)
val folderTo = Folder.valueOf(data.getStringExtra(EXTRA_FOLDER_TO)!!)
Snackbar.make(
binding!!.root,
requireContext().getQuantityString(folderTo.movedToResId(), 1),
Snackbar.LENGTH_SHORT,
)
.apply {
setAction(R.string.undo) {
model.moveBaseNotes(longArrayOf(id), folderFrom)
}
}
.show()
}
}
}
}
override fun onCreateView(
@ -152,63 +83,35 @@ abstract class NotallyFragment : Fragment(), ItemListener {
override fun onLongClick(position: Int) {
if (position != -1) {
if (model.actionMode.selectedNotes.isNotEmpty()) {
if (lastSelectedNotePosition > position) {
position..lastSelectedNotePosition
} else {
lastSelectedNotePosition..position
}
.forEach { pos ->
notesAdapter!!.getItem(pos)?.let { item ->
if (item is BaseNote) {
if (!model.actionMode.selectedNotes.contains(item.id)) {
handleNoteSelection(item.id, pos, item)
}
}
}
}
} else {
notesAdapter?.getItem(position)?.let { item ->
if (item is BaseNote) {
handleNoteSelection(item.id, position, item)
}
notesAdapter?.getItem(position)?.let { item ->
if (item is BaseNote) {
handleNoteSelection(item.id, position, item)
}
}
}
}
private fun setupSearch() {
binding?.EnterSearchKeyword?.apply {
setText(model.keyword)
val navController = findNavController()
navController.addOnDestinationChangedListener { controller, destination, arguments ->
if (destination.id == R.id.Search) {
// setText("")
visibility = View.VISIBLE
requestFocus()
activity?.showKeyboard(this)
} else {
// visibility = View.GONE
setText("")
clearFocus()
activity?.hideKeyboard(this)
}
}
doAfterTextChanged { text ->
val isSearchFragment = navController.currentDestination?.id == R.id.Search
if (isSearchFragment) {
model.keyword = requireNotNull(text).trim().toString()
}
if (text?.isNotEmpty() == true && !isSearchFragment) {
setText("")
model.keyword = text.trim().toString()
navController.navigate(
R.id.Search,
Bundle().apply {
putSerializable(EXTRA_INITIAL_FOLDER, model.folder.value)
putSerializable(EXTRA_INITIAL_LABEL, model.currentLabel)
},
)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
if (requestCode == REQUEST_NOTE_EDIT) {
// If a note has been moved inside of EditActivity
// present snackbar to undo it
val id = data?.getLongExtra(NOTE_ID, -1)
if (id != null) {
val folderFrom = Folder.valueOf(data.getStringExtra(FOLDER_FROM)!!)
val folderTo = Folder.valueOf(data.getStringExtra(FOLDER_TO)!!)
Snackbar.make(
binding!!.root,
resources.getQuantityString(folderTo.movedToResId(), 1, 1),
Snackbar.LENGTH_SHORT,
)
.apply {
setAction(R.string.undo) {
model.moveBaseNotes(longArrayOf(id), folderFrom)
}
}
.show()
}
}
}
@ -217,28 +120,21 @@ abstract class NotallyFragment : Fragment(), ItemListener {
private fun handleNoteSelection(id: Long, position: Int, baseNote: BaseNote) {
if (model.actionMode.selectedNotes.contains(id)) {
model.actionMode.remove(id)
} else {
model.actionMode.add(id, baseNote)
lastSelectedNotePosition = position
}
} else model.actionMode.add(id, baseNote)
notesAdapter?.notifyItemChanged(position, 0)
}
private fun setupAdapter() {
notesAdapter =
with(model.preferences) {
BaseNoteAdapter(
model.actionMode.selectedIds,
dateFormat.value,
notesSorting.value,
BaseNoteVHPreferences(
textSize.value,
maxItems.value,
maxLines.value,
maxTitle.value,
labelTagsHiddenInOverview.value,
imagesHiddenInOverview.value,
),
textSize.value,
maxItems,
maxLines,
maxTitle,
model.imageRoot,
this@NotallyFragment,
)
@ -248,20 +144,14 @@ abstract class NotallyFragment : Fragment(), ItemListener {
object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (itemCount > 0) {
binding?.MainListView?.scrollToPosition(positionStart)
binding?.RecyclerView?.scrollToPosition(positionStart)
}
}
}
)
binding?.MainListView?.apply {
binding?.RecyclerView?.apply {
adapter = notesAdapter
setHasFixedSize(false)
}
model.actionMode.addListener = { notesAdapter?.notifyDataSetChanged() }
if (activity is MainActivity) {
(activity as MainActivity).getCurrentFragmentNotes = {
notesAdapter?.currentList?.filterIsInstance<BaseNote>()
}
setHasFixedSize(true)
}
}
@ -271,8 +161,8 @@ abstract class NotallyFragment : Fragment(), ItemListener {
binding?.ImageView?.isVisible = list.isEmpty()
}
model.preferences.notesSorting.observe(viewLifecycleOwner) { notesSort ->
notesAdapter?.setNotesSort(notesSort)
model.preferences.notesSorting.observe(viewLifecycleOwner) { (sortBy, sortDirection) ->
notesAdapter?.setSorting(sortBy, sortDirection)
}
model.actionMode.closeListener.observe(viewLifecycleOwner) { event ->
@ -287,28 +177,23 @@ abstract class NotallyFragment : Fragment(), ItemListener {
}
private fun setupRecyclerView() {
binding?.MainListView?.layoutManager =
if (model.preferences.notesView.value == NotesView.GRID) {
binding?.RecyclerView?.layoutManager =
if (model.preferences.view.value == ViewPref.grid) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(requireContext())
}
private fun goToActivity(activity: Class<*>, baseNote: BaseNote) {
val intent = Intent(requireContext(), activity)
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, baseNote.id)
openNoteActivityResultLauncher.launch(intent)
intent.putExtra(Constants.SelectedBaseNote, baseNote.id)
startActivityForResult(intent, REQUEST_NOTE_EDIT)
}
abstract fun getBackground(): Int
abstract fun getObservable(): LiveData<List<Item>>
open fun prepareNewNoteIntent(intent: Intent): Intent {
return intent
}
companion object {
private const val EXTRA_SCROLL_POS = "notallyx.intent.extra.SCROLL_POS"
private const val EXTRA_SCROLL_OFFSET = "notallyx.intent.extra.SCROLL_OFFSET"
private const val REQUEST_NOTE_EDIT = 11
}
}

View file

@ -1,15 +1,17 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.View
import android.view.Menu
import android.view.MenuInflater
import androidx.navigation.fragment.findNavController
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.utils.add
class NotesFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.NOTES
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(R.string.search, R.drawable.search) {
findNavController().navigate(R.id.NotesToSearch)
}
}
override fun getObservable() = model.baseNotes!!

View file

@ -1,84 +0,0 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteReminder
import com.philkes.notallyx.data.model.hasAnyUpcomingNotifications
import com.philkes.notallyx.databinding.FragmentRemindersBinding
import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity
import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.view.main.reminder.NoteReminderAdapter
import com.philkes.notallyx.presentation.view.main.reminder.NoteReminderListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.getOpenNoteIntent
class RemindersFragment : Fragment(), NoteReminderListener {
private var reminderAdapter: NoteReminderAdapter? = null
private var binding: FragmentRemindersBinding? = null
private lateinit var allReminders: List<NoteReminder>
private val model: BaseNoteModel by activityViewModels()
override fun onDestroyView() {
super.onDestroyView()
binding = null
reminderAdapter = null
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
reminderAdapter = NoteReminderAdapter(this)
binding?.MainListView?.apply {
initListView(requireContext())
adapter = reminderAdapter
binding?.ImageView?.setImageResource(R.drawable.notifications)
}
binding?.ChipGroup?.setOnCheckedStateChangeListener { _, _ -> updateList() }
model.reminders.observe(viewLifecycleOwner) { reminders ->
allReminders = reminders.sortedBy { it.title }
updateList()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
setHasOptionsMenu(true)
binding = FragmentRemindersBinding.inflate(inflater)
return binding?.root
}
private fun updateList() {
val list =
when (binding?.ChipGroup?.checkedChipId) {
R.id.Upcoming -> allReminders.filter { it.reminders.hasAnyUpcomingNotifications() }
R.id.Past -> allReminders.filter { !it.reminders.hasAnyUpcomingNotifications() }
else -> allReminders
}
reminderAdapter?.submitList(list)
binding?.ImageView?.isVisible = list.isEmpty()
}
override fun openReminder(reminder: NoteReminder) {
val intent =
Intent(requireContext(), RemindersActivity::class.java).apply {
putExtra(RemindersActivity.NOTE_ID, reminder.id)
}
startActivity(intent)
}
override fun openNote(reminder: NoteReminder) {
startActivity(requireContext().getOpenNoteIntent(reminder.id, reminder.type))
}
}

View file

@ -3,59 +3,38 @@ package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Folder
class SearchFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// TODO: autofocus and show keyboard
val initialFolder =
arguments?.let {
BundleCompat.getSerializable(it, EXTRA_INITIAL_FOLDER, Folder::class.java)
}
binding?.ChipGroup?.visibility = View.VISIBLE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.MainListView?.scrollIndicators = View.SCROLL_INDICATOR_TOP
binding?.RecyclerView?.scrollIndicators = View.SCROLL_INDICATOR_TOP
}
super.onViewCreated(view, savedInstanceState)
val initialLabel = arguments?.getString(EXTRA_INITIAL_LABEL)
model.currentLabel = initialLabel
if (initialLabel?.isEmpty() == true) {
val checked =
when (initialFolder ?: model.folder.value) {
Folder.NOTES -> R.id.Notes
Folder.DELETED -> R.id.Deleted
Folder.ARCHIVED -> R.id.Archived
}
binding?.ChipGroup?.apply {
setOnCheckedStateChangeListener { _, checkedId ->
when (checkedId.first()) {
R.id.Notes -> model.folder.value = Folder.NOTES
R.id.Deleted -> model.folder.value = Folder.DELETED
R.id.Archived -> model.folder.value = Folder.ARCHIVED
}
}
check(checked)
isVisible = true
val checked =
when (model.folder) {
Folder.NOTES -> R.id.Notes
Folder.DELETED -> R.id.Deleted
Folder.ARCHIVED -> R.id.Archived
}
} else binding?.ChipGroup?.isVisible = false
getObservable().observe(viewLifecycleOwner) { items ->
model.actionMode.updateSelected(items?.filterIsInstance<BaseNote>()?.map { it.id })
binding?.ChipGroup?.apply {
setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.Notes -> model.folder = Folder.NOTES
R.id.Deleted -> model.folder = Folder.DELETED
R.id.Archived -> model.folder = Folder.ARCHIVED
}
}
check(checked)
}
}
override fun getBackground() = R.drawable.search
override fun getObservable() = model.searchResults!!
companion object {
const val EXTRA_INITIAL_FOLDER = "notallyx.intent.extra.INITIAL_FOLDER"
const val EXTRA_INITIAL_LABEL = "notallyx.intent.extra.INITIAL_LABEL"
}
}

View file

@ -0,0 +1,515 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.hardware.biometrics.BiometricManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.MutableLiveData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.ChoiceItemBinding
import com.philkes.notallyx.databinding.DialogProgressBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding
import com.philkes.notallyx.databinding.NotesSortDialogBinding
import com.philkes.notallyx.databinding.PreferenceBinding
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
import com.philkes.notallyx.presentation.view.misc.AutoBackup
import com.philkes.notallyx.presentation.view.misc.AutoBackupMax
import com.philkes.notallyx.presentation.view.misc.AutoBackupPeriodDays
import com.philkes.notallyx.presentation.view.misc.BiometricLock
import com.philkes.notallyx.presentation.view.misc.BiometricLock.disabled
import com.philkes.notallyx.presentation.view.misc.BiometricLock.enabled
import com.philkes.notallyx.presentation.view.misc.DateFormat
import com.philkes.notallyx.presentation.view.misc.ListInfo
import com.philkes.notallyx.presentation.view.misc.MaxItems
import com.philkes.notallyx.presentation.view.misc.MaxLines
import com.philkes.notallyx.presentation.view.misc.MaxTitle
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.view.misc.NotesSorting
import com.philkes.notallyx.presentation.view.misc.SeekbarInfo
import com.philkes.notallyx.presentation.view.misc.SortDirection
import com.philkes.notallyx.presentation.view.misc.TextSize
import com.philkes.notallyx.presentation.view.misc.Theme
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.utils.backup.BackupProgress
import com.philkes.notallyx.utils.backup.scheduleAutoBackup
import com.philkes.notallyx.utils.canAuthenticateWithBiometrics
import com.philkes.notallyx.utils.checkedTag
import com.philkes.notallyx.utils.security.decryptDatabase
import com.philkes.notallyx.utils.security.encryptDatabase
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
class SettingsFragment : Fragment() {
private val model: BaseNoteModel by activityViewModels()
private fun setupBinding(binding: FragmentSettingsBinding) {
model.preferences.apply {
view.observe(viewLifecycleOwner) { value ->
binding.View.setup(com.philkes.notallyx.presentation.view.misc.View, value)
}
theme.observe(viewLifecycleOwner) { value -> binding.Theme.setup(Theme, value) }
dateFormat.observe(viewLifecycleOwner) { value ->
binding.DateFormat.setup(DateFormat, value)
}
textSize.observe(viewLifecycleOwner) { value ->
binding.TextSize.setup(TextSize, value)
}
notesSorting.observe(viewLifecycleOwner) { (sortBy, sortDirection) ->
binding.NotesSortOrder.setup(NotesSorting, sortBy, sortDirection)
}
// TODO: Hide for now until checked auto-sort is working reliably
// listItemSorting.observe(viewLifecycleOwner) { value ->
// binding.CheckedListItemSorting.setup(ListItemSorting, value)
// }
binding.MaxItems.setup(MaxItems, maxItems)
binding.MaxLines.setup(MaxLines, maxLines)
binding.MaxTitle.setup(MaxTitle, maxTitle)
binding.AutoBackupMax.setup(AutoBackupMax, autoBackupMax)
autoBackupPath.observe(viewLifecycleOwner) { value ->
binding.AutoBackup.setup(AutoBackup, value)
}
autoBackupPeriodDays.observe(viewLifecycleOwner) { value ->
binding.AutoBackupPeriodDays.setup(AutoBackupPeriodDays, value)
scheduleAutoBackup(value.toLong(), requireContext())
}
biometricLock.observe(viewLifecycleOwner) { value ->
binding.BiometricLock.setup(BiometricLock, value)
}
}
binding.ImportBackup.setOnClickListener { importBackup() }
binding.ExportBackup.setOnClickListener { exportBackup() }
setupProgressDialog(R.string.exporting_backup, model.exportingBackup)
setupProgressDialog(R.string.importing_backup, model.importingBackup)
binding.GitHub.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
binding.Libraries.setOnClickListener { displayLibraries() }
binding.Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
binding.SendFeedback.setOnClickListener { sendFeedback() }
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentSettingsBinding.inflate(inflater)
setupBinding(binding)
return binding.root
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
if (resultCode == Activity.RESULT_OK) {
intent?.data?.let { uri ->
when (requestCode) {
REQUEST_IMPORT_BACKUP -> model.importBackup(uri)
REQUEST_EXPORT_BACKUP -> model.exportBackup(uri)
REQUEST_CHOOSE_FOLDER -> model.setAutoBackupPath(uri)
}
return
}
}
when (requestCode) {
REQUEST_SETUP_LOCK -> showEnableBiometricLock()
REQUEST_DISABLE_LOCK -> showDisableBiometricLock()
}
}
private fun exportBackup() {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "application/zip"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX Backup")
}
startActivityForResult(intent, REQUEST_EXPORT_BACKUP)
}
private fun importBackup() {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/zip", "text/xml"))
addCategory(Intent.CATEGORY_OPENABLE)
}
startActivityForResult(intent, REQUEST_IMPORT_BACKUP)
}
private fun setupProgressDialog(titleId: Int, liveData: MutableLiveData<BackupProgress>) {
val dialogBinding = DialogProgressBinding.inflate(layoutInflater)
val dialog =
MaterialAlertDialogBuilder(requireContext())
.setTitle(titleId)
.setView(dialogBinding.root)
.setCancelable(false)
.create()
liveData.observe(viewLifecycleOwner) { progress ->
if (progress.inProgress) {
if (progress.indeterminate) {
dialogBinding.apply {
ProgressBar.isIndeterminate = true
Count.setText(R.string.calculating)
}
} else {
dialogBinding.apply {
ProgressBar.max = progress.total
ProgressBar.setProgressCompat(progress.current, true)
Count.text = getString(R.string.count, progress.current, progress.total)
}
}
dialog.show()
} else dialog.dismiss()
}
}
private fun sendFeedback() {
val intent =
Intent(
Intent.ACTION_VIEW,
Uri.parse("https://github.com/PhilKes/NotallyX/issues/new/choose"),
)
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.install_a_browser, Toast.LENGTH_LONG).show()
}
}
private fun displayLibraries() {
val libraries =
arrayOf(
"Glide",
"Pretty Time",
"Swipe Layout",
"Work Manager",
"Subsampling Scale ImageView",
"Material Components for Android",
"SQLCipher",
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries)
.setItems(libraries) { _, which ->
when (which) {
0 -> openLink("https://github.com/bumptech/glide")
1 -> openLink("https://github.com/ocpsoft/prettytime")
2 -> openLink("https://github.com/zerobranch/SwipeLayout")
3 -> openLink("https://developer.android.com/jetpack/androidx/releases/work")
4 -> openLink("https://github.com/davemorrissey/subsampling-scale-image-view")
5 ->
openLink(
"https://github.com/material-components/material-components-android"
)
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun displayChooseFolderDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.notes_will_be)
.setPositiveButton(R.string.choose_folder) { _, _ ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CHOOSE_FOLDER)
}
.show()
}
private fun PreferenceBinding.setup(info: ListInfo, value: String) {
Title.setText(info.title)
val entries = info.getEntries(requireContext())
val entryValues = info.getEntryValues()
val checked = entryValues.indexOf(value)
val displayValue = entries[checked]
Value.text = displayValue
root.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = entryValues[which]
model.savePreference(info, newValue)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
private fun PreferenceBinding.setup(
info: NotesSorting,
sortBy: String,
sortDirection: SortDirection,
) {
Title.setText(info.title)
val entries = info.getEntries(requireContext())
val entryValues = info.getEntryValues()
val checked = entryValues.indexOf(sortBy)
val displayValue = entries[checked]
Value.text = "$displayValue (${requireContext().getString(sortDirection.textResId)})"
root.setOnClickListener {
val layout = NotesSortDialogBinding.inflate(layoutInflater, null, false)
entries.zip(entryValues).forEachIndexed { idx, (choiceText, sortByValue) ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = choiceText
tag = sortByValue
layout.NotesSortByRadioGroup.addView(this)
setCompoundDrawablesRelativeWithIntrinsicBounds(
NotesSorting.getSortIconResId(sortByValue),
0,
0,
0,
)
if (sortByValue == sortBy) {
layout.NotesSortByRadioGroup.check(this.id)
}
}
}
SortDirection.entries.forEachIndexed { idx, sortDir ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = requireContext().getString(sortDir.textResId)
tag = sortDir
setCompoundDrawablesRelativeWithIntrinsicBounds(sortDir.iconResId, 0, 0, 0)
layout.NotesSortDirectionRadioGroup.addView(this)
if (sortDir == sortDirection) {
layout.NotesSortDirectionRadioGroup.check(this.id)
}
}
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newSortBy = layout.NotesSortByRadioGroup.checkedTag() as String
val newSortDirection =
layout.NotesSortDirectionRadioGroup.checkedTag() as SortDirection
model.preferences.savePreference(info, newSortBy, newSortDirection)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
private fun PreferenceBinding.setup(info: BiometricLock, value: String) {
Title.setText(info.title)
val entries = info.getEntries(requireContext())
val entryValues = info.getEntryValues()
val checked = entryValues.indexOf(value)
val displayValue = entries[checked]
Value.text = displayValue
root.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(info.title)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = entryValues[which]
if (newValue == enabled) {
when (requireContext().canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> showEnableBiometricLock()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
showNoBiometricsSupportToast()
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
showBiometricsNotSetupDialog()
}
} else {
when (requireContext().canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> showDisableBiometricLock()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
showNoBiometricsSupportToast()
model.preferences.biometricLock.value = disabled
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
showBiometricsNotSetupDialog()
model.preferences.biometricLock.value = disabled
}
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
private fun showEnableBiometricLock() {
showBiometricOrPinPrompt(
false,
REQUEST_SETUP_LOCK,
R.string.enable_lock_title,
R.string.enable_lock_description,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.preferences.iv = cipher.iv
val passphrase = model.preferences.generatePassphrase(cipher)
encryptDatabase(requireContext(), passphrase)
model.savePreference(BiometricLock, enabled)
}
(activity?.application as NotallyXApplication).isLocked = false
showBiometricsEnabledToast()
},
) {
showBiometricsNotSetupDialog()
}
}
private fun showDisableBiometricLock() {
showBiometricOrPinPrompt(
true,
REQUEST_DISABLE_LOCK,
R.string.disable_lock_title,
R.string.disable_lock_description,
model.preferences.iv!!,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val encryptedPassphrase = model.preferences.getDatabasePassphrase()
val passphrase = cipher.doFinal(encryptedPassphrase)
model.closeDatabase()
decryptDatabase(requireContext(), passphrase)
model.savePreference(BiometricLock, disabled)
}
showBiometricsDisabledToast()
},
) {}
}
private fun showNoBiometricsSupportToast() {
ContextCompat.getMainExecutor(requireContext()).execute {
Toast.makeText(requireContext(), R.string.biometrics_setup_success, Toast.LENGTH_LONG)
.show()
}
}
private fun showBiometricsEnabledToast() {
ContextCompat.getMainExecutor(requireContext()).execute {
Toast.makeText(requireContext(), R.string.biometrics_setup_success, Toast.LENGTH_LONG)
.show()
}
}
private fun showBiometricsDisabledToast() {
ContextCompat.getMainExecutor(requireContext()).execute {
Toast.makeText(requireContext(), R.string.biometrics_disable_success, Toast.LENGTH_LONG)
.show()
}
}
private fun showBiometricsNotSetupDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.biometrics_not_setup)
.setNegativeButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.tap_to_set_up) { _, _ ->
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
startActivityForResult(intent, REQUEST_SETUP_LOCK)
}
.show()
}
private fun PreferenceBinding.setup(info: AutoBackup, value: String) {
Title.setText(info.title)
if (value == info.emptyPath) {
Value.setText(R.string.tap_to_set_up)
root.setOnClickListener { displayChooseFolderDialog() }
} else {
val uri = Uri.parse(value)
val folder = requireNotNull(DocumentFile.fromTreeUri(requireContext(), uri))
if (folder.exists()) {
Value.text = folder.name
} else Value.setText(R.string.cant_find_folder)
root.setOnClickListener {
MenuDialog(requireContext())
.add(R.string.disable_auto_backup) { model.disableAutoBackup() }
.add(R.string.choose_another_folder) { displayChooseFolderDialog() }
.show()
}
}
}
private fun PreferenceSeekbarBinding.setup(info: SeekbarInfo, initialValue: Int) {
Title.setText(info.title)
Slider.apply {
valueTo = info.max.toFloat()
valueFrom = info.min.toFloat()
value = initialValue.toFloat()
addOnChangeListener { _, value, _ -> model.savePreference(info, value.toInt()) }
}
}
private fun openLink(link: String) {
val uri = Uri.parse(link)
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.install_a_browser, Toast.LENGTH_LONG).show()
}
}
companion object {
private const val REQUEST_IMPORT_BACKUP = 20
private const val REQUEST_EXPORT_BACKUP = 21
private const val REQUEST_CHOOSE_FOLDER = 22
private const val REQUEST_SETUP_LOCK = 23
private const val REQUEST_DISABLE_LOCK = 24
}
}

View file

@ -1,22 +0,0 @@
package com.philkes.notallyx.presentation.activity.main.fragment
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Folder
import com.philkes.notallyx.data.model.Item
class UnlabeledFragment : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.folder.value = Folder.NOTES
}
override fun getBackground() = R.drawable.label_off
override fun getObservable(): LiveData<List<Item>> {
return model.getNotesWithoutLabel()
}
}

View file

@ -1,559 +0,0 @@
package com.philkes.notallyx.presentation.activity.main.fragment.settings
import android.content.Context
import android.hardware.biometrics.BiometricManager
import android.net.Uri
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.color.DynamicColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.ChoiceItemBinding
import com.philkes.notallyx.databinding.DialogNotesSortBinding
import com.philkes.notallyx.databinding.DialogPreferenceBooleanBinding
import com.philkes.notallyx.databinding.DialogPreferenceEnumWithToggleBinding
import com.philkes.notallyx.databinding.DialogSelectionBoxBinding
import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.PreferenceBinding
import com.philkes.notallyx.databinding.PreferenceSeekbarBinding
import com.philkes.notallyx.presentation.checkedTag
import com.philkes.notallyx.presentation.select
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.MenuDialog
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.BiometricLock
import com.philkes.notallyx.presentation.viewmodel.preference.BooleanPreference
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.EnumPreference
import com.philkes.notallyx.presentation.viewmodel.preference.IntPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_DEFAULT
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.START_VIEW_UNLABELED
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortPreference
import com.philkes.notallyx.presentation.viewmodel.preference.SortDirection
import com.philkes.notallyx.presentation.viewmodel.preference.StringPreference
import com.philkes.notallyx.presentation.viewmodel.preference.TextProvider
import com.philkes.notallyx.presentation.viewmodel.preference.Theme
import com.philkes.notallyx.utils.canAuthenticateWithBiometrics
import com.philkes.notallyx.utils.toReadablePath
inline fun <reified T> PreferenceBinding.setup(
enumPreference: EnumPreference<T>,
value: T,
context: Context,
crossinline onSave: (newValue: T) -> Unit,
) where T : Enum<T>, T : TextProvider {
Title.setText(enumPreference.titleResId!!)
Value.text = value.getText(context)
val enumEntries = T::class.java.enumConstants!!.toList()
val entries = enumEntries.map { it.getText(context) }.toTypedArray()
val checked = enumEntries.indexOfFirst { it == value }
root.setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle(enumPreference.titleResId)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = enumEntries[which]
onSave(newValue)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
preference: EnumPreference<BiometricLock>,
value: BiometricLock,
context: Context,
model: BaseNoteModel,
onEnableSuccess: () -> Unit,
onDisableSuccess: () -> Unit,
onNotSetup: () -> Unit,
) {
Title.setText(preference.titleResId!!)
Value.text = value.getText(context)
val enumEntries = BiometricLock.entries
val entries = enumEntries.map { context.getString(it.textResId) }.toTypedArray()
val checked = enumEntries.indexOfFirst { it == value }
root.setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setSingleChoiceItems(entries, checked) { dialog, which ->
dialog.cancel()
val newValue = enumEntries[which]
if (newValue == value) {
return@setSingleChoiceItems
}
if (newValue == BiometricLock.ENABLED) {
when (context.canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> onEnableSuccess()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
context.showToast(R.string.biometrics_no_support)
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> onNotSetup()
}
} else {
when (context.canAuthenticateWithBiometrics()) {
BiometricManager.BIOMETRIC_SUCCESS -> onDisableSuccess()
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
context.showToast(R.string.biometrics_no_support)
model.savePreference(
model.preferences.biometricLock,
BiometricLock.DISABLED,
)
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onNotSetup()
model.savePreference(
model.preferences.biometricLock,
BiometricLock.DISABLED,
)
}
}
}
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
preference: NotesSortPreference,
value: NotesSort,
context: Context,
layoutInflater: LayoutInflater,
model: BaseNoteModel,
) {
Title.setText(preference.titleResId!!)
Value.text = value.getText(context)
root.setOnClickListener {
val layout = DialogNotesSortBinding.inflate(layoutInflater, null, false)
NotesSortBy.entries.forEachIndexed { idx, notesSortBy ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = context.getString(notesSortBy.textResId)
tag = notesSortBy
layout.NotesSortByRadioGroup.addView(this)
setCompoundDrawablesRelativeWithIntrinsicBounds(notesSortBy.iconResId, 0, 0, 0)
if (notesSortBy == value.sortedBy) {
layout.NotesSortByRadioGroup.check(this.id)
}
}
}
SortDirection.entries.forEachIndexed { idx, sortDir ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = context.getString(sortDir.textResId)
tag = sortDir
setCompoundDrawablesRelativeWithIntrinsicBounds(sortDir.iconResId, 0, 0, 0)
layout.NotesSortDirectionRadioGroup.addView(this)
if (sortDir == value.sortDirection) {
layout.NotesSortDirectionRadioGroup.check(this.id)
}
}
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newSortBy = layout.NotesSortByRadioGroup.checkedTag() as NotesSortBy
val newSortDirection =
layout.NotesSortDirectionRadioGroup.checkedTag() as SortDirection
model.savePreference(
model.preferences.notesSorting,
NotesSort(newSortBy, newSortDirection),
)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
dateFormatPreference: EnumPreference<DateFormat>,
dateFormatValue: DateFormat,
applyToNoteViewValue: Boolean,
context: Context,
layoutInflater: LayoutInflater,
onSave: (dateFormat: DateFormat, applyToEditMode: Boolean) -> Unit,
) {
Title.setText(dateFormatPreference.titleResId!!)
Value.text = dateFormatValue.getText(context)
root.setOnClickListener {
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
layout.EnumHint.apply {
setText(R.string.date_format_hint)
isVisible = true
}
DateFormat.entries.forEachIndexed { idx, dateFormat ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = dateFormat.getText(context)
tag = dateFormat
layout.EnumRadioGroup.addView(this)
if (dateFormat == dateFormatValue) {
layout.EnumRadioGroup.check(this.id)
}
}
}
layout.Toggle.apply {
setText(R.string.date_format_apply_in_note_view)
isChecked = applyToNoteViewValue
}
MaterialAlertDialogBuilder(context)
.setTitle(dateFormatPreference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val dateFormat = layout.EnumRadioGroup.checkedTag() as DateFormat
val applyToNoteView = layout.Toggle.isChecked
onSave(dateFormat, applyToNoteView)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
themePreference: EnumPreference<Theme>,
themeValue: Theme,
useDynamicColorsValue: Boolean,
context: Context,
layoutInflater: LayoutInflater,
onSave: (theme: Theme, useDynamicColors: Boolean) -> Unit,
) {
Title.setText(themePreference.titleResId!!)
Value.text = themeValue.getText(context)
root.setOnClickListener {
val layout = DialogPreferenceEnumWithToggleBinding.inflate(layoutInflater, null, false)
Theme.entries.forEachIndexed { idx, theme ->
ChoiceItemBinding.inflate(layoutInflater).root.apply {
id = idx
text = theme.getText(context)
tag = theme
layout.EnumRadioGroup.addView(this)
if (theme == themeValue) {
layout.EnumRadioGroup.check(this.id)
}
}
}
layout.Toggle.apply {
isVisible = DynamicColors.isDynamicColorAvailable()
setText(R.string.theme_use_dynamic_colors)
isChecked = useDynamicColorsValue
}
MaterialAlertDialogBuilder(context)
.setTitle(themePreference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val theme = layout.EnumRadioGroup.checkedTag() as Theme
val useDynamicColors = layout.Toggle.isChecked
onSave(theme, useDynamicColors)
}
.setCancelButton()
.show()
}
}
fun PreferenceBinding.setup(
preference: BooleanPreference,
value: Boolean,
context: Context,
layoutInflater: LayoutInflater,
messageResId: Int? = null,
enabled: Boolean = true,
disabledTextResId: Int? = null,
onSave: (newValue: Boolean) -> Unit,
) {
Title.setText(preference.titleResId!!)
if (enabled) {
Value.setText(if (value) R.string.enabled else R.string.disabled)
} else {
disabledTextResId?.let { Value.setText(it) }
}
root.isEnabled = enabled
root.setOnClickListener {
val layout =
DialogPreferenceBooleanBinding.inflate(layoutInflater, null, false).apply {
Title.setText(preference.titleResId)
messageResId?.let { Message.setText(it) }
if (value) {
EnabledButton.isChecked = true
} else {
DisabledButton.isChecked = true
}
}
val dialog =
MaterialAlertDialogBuilder(context).setView(layout.root).setCancelButton().show()
layout.apply {
EnabledButton.setOnClickListener {
dialog.cancel()
if (!value) {
onSave.invoke(true)
}
}
DisabledButton.setOnClickListener {
dialog.cancel()
if (value) {
onSave.invoke(false)
}
}
}
}
}
fun PreferenceBinding.setupPeriodicBackup(
value: Boolean,
context: Context,
layoutInflater: LayoutInflater,
enabled: Boolean,
onSave: (newValue: Boolean) -> Unit,
) {
Title.setText(R.string.backup_periodic)
val enabledText = context.getString(R.string.enabled)
val disabledText = context.getString(R.string.disabled)
val text =
if (enabled) {
if (value) enabledText else disabledText
} else context.getString(R.string.auto_backups_folder_set)
Value.text = text
root.isEnabled = enabled
root.setOnClickListener {
val layout =
DialogPreferenceBooleanBinding.inflate(layoutInflater, null, false).apply {
Title.setText(R.string.backup_periodic)
Message.setText(R.string.backup_periodic_hint)
if (value) {
EnabledButton.isChecked = true
} else {
DisabledButton.isChecked = true
}
}
val dialog =
MaterialAlertDialogBuilder(context).setView(layout.root).setCancelButton().show()
layout.apply {
EnabledButton.setOnClickListener {
dialog.cancel()
if (!value) {
onSave.invoke(true)
}
}
DisabledButton.setOnClickListener {
dialog.cancel()
if (value) {
onSave.invoke(false)
}
}
}
}
}
fun PreferenceBinding.setupBackupPassword(
preference: StringPreference,
password: String,
context: Context,
layoutInflater: LayoutInflater,
onSave: (newValue: String) -> Unit,
) {
Title.setText(preference.titleResId!!)
Value.transformationMethod =
if (password != PASSWORD_EMPTY) PasswordTransformationMethod.getInstance() else null
Value.text =
if (password != PASSWORD_EMPTY) password else context.getText(R.string.tap_to_set_up)
root.setOnClickListener {
val layout = DialogTextInputBinding.inflate(layoutInflater, null, false)
layout.InputText.apply {
if (password != PASSWORD_EMPTY) {
setText(password)
}
transformationMethod = PasswordTransformationMethod.getInstance()
}
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
layout.Message.apply {
setText(R.string.backup_password_hint)
visibility = View.VISIBLE
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val updatedPassword = layout.InputText.text.toString()
onSave(updatedPassword)
}
.setCancelButton()
.setNeutralButton(R.string.clear) { dialog, _ ->
dialog.cancel()
onSave(PASSWORD_EMPTY)
}
.showAndFocus(allowFullSize = true)
}
}
fun PreferenceBinding.setupBackupsFolder(
value: String,
context: Context,
chooseBackupFolder: () -> Unit,
onDisable: () -> Unit,
) {
Title.setText(R.string.auto_backups_folder)
if (value == EMPTY_PATH) {
Value.setText(R.string.tap_to_set_up)
root.setOnClickListener { chooseBackupFolder() }
} else {
val uri = Uri.parse(value)
val folder = requireNotNull(DocumentFile.fromTreeUri(context, uri))
if (folder.exists()) {
val path = uri.toReadablePath()
Value.text = path
} else Value.setText(R.string.cant_find_folder)
root.setOnClickListener {
MenuDialog(context)
.add(R.string.clear) { onDisable() }
.add(R.string.choose_another_folder) { chooseBackupFolder() }
.show()
}
}
}
fun PreferenceSeekbarBinding.setup(
value: Int,
titleResId: Int,
min: Int,
max: Int,
context: Context,
enabled: Boolean = true,
onChange: (newValue: Int) -> Unit,
) {
Title.setText(titleResId)
val valueInBoundaries = (if (value < min) min else if (value > max) max else value).toFloat()
Slider.apply {
isEnabled = enabled
valueTo = max.toFloat()
valueFrom = min.toFloat()
this@apply.value = valueInBoundaries
clearOnSliderTouchListeners()
addOnSliderTouchListener(
object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}
override fun onStopTrackingTouch(slider: Slider) {
onChange(slider.value.toInt())
}
}
)
contentDescription = context.getString(titleResId)
}
}
fun PreferenceSeekbarBinding.setup(
preference: IntPreference,
context: Context,
value: Int = preference.value,
onChange: (newValue: Int) -> Unit,
) {
setup(value, preference.titleResId!!, preference.min, preference.max, context) { newValue ->
onChange(newValue)
}
}
fun PreferenceSeekbarBinding.setupAutoSaveIdleTime(
preference: IntPreference,
context: Context,
value: Int = preference.value,
onChange: (newValue: Int) -> Unit,
) {
Slider.apply {
setLabelFormatter { sliderValue ->
if (sliderValue == -1f) {
context.getString(R.string.disabled)
} else "${sliderValue.toInt()}s"
}
addOnChangeListener { _, value, _ ->
if (value == -1f) {
setAlpha(0.6f) // Reduce opacity to make it look disabled
} else {
setAlpha(1f) // Restore normal appearance
}
}
}
setup(preference, context, value, onChange)
}
fun PreferenceBinding.setupStartView(
preference: StringPreference,
value: String,
labels: List<String>?,
context: Context,
layoutInflater: LayoutInflater,
onSave: (value: String) -> Unit,
) {
Title.setText(preference.titleResId!!)
val notesText = "${context.getText(R.string.notes)} (${context.getText(R.string.text_default)})"
val unlabeledText = context.getText(R.string.unlabeled).toString()
val textValue =
when (value) {
START_VIEW_DEFAULT -> notesText
START_VIEW_UNLABELED -> unlabeledText
else -> value
}
Value.text = textValue
root.setOnClickListener {
val layout = DialogSelectionBoxBinding.inflate(layoutInflater, null, false)
layout.Message.setText(R.string.start_view_hint)
val values =
mutableListOf(notesText to START_VIEW_DEFAULT, unlabeledText to START_VIEW_UNLABELED)
.apply { labels?.forEach { add(it to it) } }
var selected = -1
layout.SelectionBox.apply {
setSimpleItems(values.map { it.first }.toTypedArray())
select(textValue)
setOnItemClickListener { _, _, position, _ -> selected = position }
}
MaterialAlertDialogBuilder(context)
.setTitle(preference.titleResId)
.setView(layout.root)
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.cancel()
val newValue = values[selected].second
onSave(newValue)
}
.setCancelButton()
.showAndFocus(allowFullSize = true)
}
}

View file

@ -1,876 +0,0 @@
package com.philkes.notallyx.presentation.activity.main.fragment.settings
import android.Manifest
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT_TREE
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.Settings
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout.END_ICON_PASSWORD_TOGGLE
import com.philkes.notallyx.NotallyXApplication
import com.philkes.notallyx.R
import com.philkes.notallyx.data.imports.FOLDER_OR_FILE_MIMETYPE
import com.philkes.notallyx.data.imports.ImportSource
import com.philkes.notallyx.data.imports.txt.APPLICATION_TEXT_MIME_TYPES
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.DialogTextInputBinding
import com.philkes.notallyx.databinding.FragmentSettingsBinding
import com.philkes.notallyx.presentation.activity.main.MainActivity
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.setEnabledSecureFlag
import com.philkes.notallyx.presentation.setupImportProgressDialog
import com.philkes.notallyx.presentation.setupProgressDialog
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showDialog
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.misc.TextWithIconAdapter
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.Constants.PASSWORD_EMPTY
import com.philkes.notallyx.presentation.viewmodel.preference.LongPreference
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences.Companion.EMPTY_PATH
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_MAX_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackup.Companion.BACKUP_PERIOD_DAYS_MIN
import com.philkes.notallyx.presentation.viewmodel.preference.PeriodicBackupsPreference
import com.philkes.notallyx.utils.MIME_TYPE_JSON
import com.philkes.notallyx.utils.MIME_TYPE_ZIP
import com.philkes.notallyx.utils.backup.exportPreferences
import com.philkes.notallyx.utils.catchNoBrowserInstalled
import com.philkes.notallyx.utils.getExtraBooleanFromBundleOrIntent
import com.philkes.notallyx.utils.getLastExceptionLog
import com.philkes.notallyx.utils.getLogFile
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.reportBug
import com.philkes.notallyx.utils.security.showBiometricOrPinPrompt
import com.philkes.notallyx.utils.wrapWithChooser
import java.util.Date
class SettingsFragment : Fragment() {
private val model: BaseNoteModel by activityViewModels()
private lateinit var importBackupActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var importOtherActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportBackupActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var chooseBackupFolderActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var setupLockActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var disableLockActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var exportSettingsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var importSettingsActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var selectedImportSource: ImportSource
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = FragmentSettingsBinding.inflate(inflater)
model.preferences.apply {
setupAppearance(binding)
setupContentDensity(binding)
setupBackup(binding)
setupAutoBackups(binding)
setupSecurity(binding)
setupSettings(binding)
}
setupAbout(binding)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupActivityResultLaunchers()
val showImportBackupsFolder =
getExtraBooleanFromBundleOrIntent(
savedInstanceState,
EXTRA_SHOW_IMPORT_BACKUPS_FOLDER,
false,
)
showImportBackupsFolder.let {
if (it) {
model.refreshBackupsFolder(
requireContext(),
askForUriPermissions = ::askForUriPermissions,
)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (model.showRefreshBackupsFolderAfterThemeChange) {
outState.putBoolean(EXTRA_SHOW_IMPORT_BACKUPS_FOLDER, true)
}
}
private fun setupActivityResultLaunchers() {
importBackupActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { importBackup(it) }
}
}
importOtherActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.importFromOtherApp(uri, selectedImportSource)
}
}
}
exportBackupActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> model.exportBackup(uri) }
}
}
chooseBackupFolderActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.setupBackupsFolder(uri)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity?.let {
val permission = Manifest.permission.POST_NOTIFICATIONS
if (
it.checkSelfPermission(permission) !=
PackageManager.PERMISSION_GRANTED
) {
MaterialAlertDialogBuilder(it)
.setMessage(
R.string.please_grant_notally_notification_auto_backup
)
.setNegativeButton(R.string.skip, null)
.setPositiveButton(R.string.continue_) { _, _ ->
it.requestPermissions(arrayOf(permission), 0)
}
.show()
}
}
}
}
}
}
setupLockActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
showEnableBiometricLock()
}
disableLockActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
showDisableBiometricLock()
}
exportSettingsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
if (requireContext().exportPreferences(model.preferences, uri)) {
showToast(R.string.export_settings_success)
} else {
showToast(R.string.export_settings_failure)
}
}
}
}
importSettingsActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
model.importPreferences(
requireContext(),
uri,
::askForUriPermissions,
{ showToast(R.string.import_settings_success) },
) {
showToast(R.string.import_settings_failure)
}
}
}
}
}
private fun importBackup(uri: Uri) {
when (requireContext().contentResolver.getType(uri)) {
"text/xml" -> {
model.importXmlBackup(uri)
}
MIME_TYPE_ZIP -> {
val layout = DialogTextInputBinding.inflate(layoutInflater, null, false)
val password = model.preferences.backupPassword.value
layout.InputText.apply {
if (password != PASSWORD_EMPTY) {
setText(password)
}
transformationMethod = PasswordTransformationMethod.getInstance()
}
layout.InputTextLayout.endIconMode = END_ICON_PASSWORD_TOGGLE
layout.Message.apply {
setText(R.string.import_backup_password_hint)
visibility = View.VISIBLE
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.backup_password)
.setView(layout.root)
.setPositiveButton(R.string.import_backup) { dialog, _ ->
dialog.cancel()
val usedPassword = layout.InputText.text.toString()
model.importZipBackup(uri, usedPassword)
}
.setCancelButton()
.show()
}
}
}
private fun NotallyXPreferences.setupAppearance(binding: FragmentSettingsBinding) {
notesView.observe(viewLifecycleOwner) { value ->
binding.View.setup(notesView, value, requireContext()) { newValue ->
model.savePreference(notesView, newValue)
}
}
theme.merge(useDynamicColors).observe(viewLifecycleOwner) {
(themeValue, useDynamicColorsValue) ->
binding.Theme.setup(
theme,
themeValue,
useDynamicColorsValue,
requireContext(),
layoutInflater,
) { newThemeValue, newUseDynamicColorsValue ->
model.savePreference(theme, newThemeValue)
model.savePreference(useDynamicColors, newUseDynamicColorsValue)
val packageManager = requireContext().packageManager
val intent = packageManager.getLaunchIntentForPackage(requireContext().packageName)
val componentName = intent!!.component
val mainIntent =
Intent.makeRestartActivityTask(componentName).apply {
putExtra(MainActivity.EXTRA_FRAGMENT_TO_OPEN, R.id.Settings)
}
mainIntent.setPackage(requireContext().packageName)
requireContext().startActivity(mainIntent)
Runtime.getRuntime().exit(0)
}
}
dateFormat.merge(applyDateFormatInNoteView).observe(viewLifecycleOwner) {
(dateFormatValue, applyDateFormatInEditNoteValue) ->
binding.DateFormat.setup(
dateFormat,
dateFormatValue,
applyDateFormatInEditNoteValue,
requireContext(),
layoutInflater,
) { newDateFormatValue, newApplyDateFormatInEditNote ->
model.savePreference(dateFormat, newDateFormatValue)
model.savePreference(applyDateFormatInNoteView, newApplyDateFormatInEditNote)
}
}
textSize.observe(viewLifecycleOwner) { value ->
binding.TextSize.setup(textSize, value, requireContext()) { newValue ->
model.savePreference(textSize, newValue)
}
}
notesSorting.observe(viewLifecycleOwner) { notesSort ->
binding.NotesSortOrder.setup(
notesSorting,
notesSort,
requireContext(),
layoutInflater,
model,
)
}
listItemSorting.observe(viewLifecycleOwner) { value ->
binding.CheckedListItemSorting.setup(listItemSorting, value, requireContext()) {
newValue ->
model.savePreference(listItemSorting, newValue)
}
}
binding.MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue)
}
startView.merge(model.labels).observe(viewLifecycleOwner) { (startViewValue, labelsValue) ->
binding.StartView.setupStartView(
startView,
startViewValue,
labelsValue,
requireContext(),
layoutInflater,
) { newValue ->
model.savePreference(startView, newValue)
}
}
}
private fun NotallyXPreferences.setupContentDensity(binding: FragmentSettingsBinding) {
binding.apply {
MaxTitle.setup(maxTitle, requireContext()) { newValue ->
model.savePreference(maxTitle, newValue)
}
MaxItems.setup(maxItems, requireContext()) { newValue ->
model.savePreference(maxItems, newValue)
}
MaxLines.setup(maxLines, requireContext()) { newValue ->
model.savePreference(maxLines, newValue)
}
MaxLabels.setup(maxLabels, requireContext()) { newValue ->
model.savePreference(maxLabels, newValue)
}
labelTagsHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.LabelsHiddenInOverview.setup(
labelTagsHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.labels_hidden_in_overview,
) { enabled ->
model.savePreference(labelTagsHiddenInOverview, enabled)
}
}
imagesHiddenInOverview.observe(viewLifecycleOwner) { value ->
binding.ImagesHiddenInOverview.setup(
imagesHiddenInOverview,
value,
requireContext(),
layoutInflater,
R.string.images_hidden_in_overview,
) { enabled ->
model.savePreference(imagesHiddenInOverview, enabled)
}
}
}
}
private fun NotallyXPreferences.setupBackup(binding: FragmentSettingsBinding) {
binding.apply {
ImportBackup.setOnClickListener {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf(MIME_TYPE_ZIP, "text/xml"))
addCategory(Intent.CATEGORY_OPENABLE)
}
.wrapWithChooser(requireContext())
importBackupActivityResultLauncher.launch(intent)
}
ImportOther.setOnClickListener { importFromOtherApp() }
ExportBackup.setOnClickListener {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = MIME_TYPE_ZIP
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX Backup")
}
.wrapWithChooser(requireContext())
exportBackupActivityResultLauncher.launch(intent)
}
}
model.exportProgress.setupProgressDialog(this@SettingsFragment, R.string.exporting_backup)
model.importProgress.setupImportProgressDialog(
this@SettingsFragment,
R.string.importing_backup,
)
}
private fun NotallyXPreferences.setupAutoBackups(binding: FragmentSettingsBinding) {
backupsFolder.observe(viewLifecycleOwner) { value ->
binding.BackupsFolder.setupBackupsFolder(
value,
requireContext(),
::displayChooseBackupFolderDialog,
) {
model.disableBackups()
}
}
backupOnSave.merge(backupsFolder).observe(viewLifecycleOwner) { (onSave, backupFolder) ->
binding.BackupOnSave.setup(
backupOnSave,
onSave,
requireContext(),
layoutInflater,
messageResId = R.string.auto_backup_on_save,
enabled = backupFolder != EMPTY_PATH,
disabledTextResId = R.string.auto_backups_folder_set,
) { enabled ->
model.savePreference(backupOnSave, enabled)
}
}
periodicBackups.merge(backupsFolder).observe(viewLifecycleOwner) {
(periodicBackup, backupFolder) ->
setupPeriodicBackup(
binding,
periodicBackup,
backupFolder,
periodicBackups,
periodicBackupLastExecution,
)
}
}
private fun importFromOtherApp() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.choose_other_app)
.setAdapter(
TextWithIconAdapter(
requireContext(),
ImportSource.entries.toMutableList(),
{ item -> getString(item.displayNameResId) },
ImportSource::iconResId,
)
) { _, which ->
selectedImportSource = ImportSource.entries[which]
MaterialAlertDialogBuilder(requireContext())
.setMessage(selectedImportSource.helpTextResId)
.setPositiveButton(R.string.import_action) { dialog, _ ->
dialog.cancel()
when (selectedImportSource.mimeType) {
FOLDER_OR_FILE_MIMETYPE ->
MaterialAlertDialogBuilder(requireContext())
.setTitle(selectedImportSource.displayNameResId)
.setItems(
arrayOf(
getString(R.string.folder),
getString(R.string.single_file),
)
) { _, which ->
when (which) {
0 ->
importOtherActivityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.apply {
addCategory(Intent.CATEGORY_DEFAULT)
}
.wrapWithChooser(requireContext())
)
1 ->
importOtherActivityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = "text/*"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(
Intent.EXTRA_MIME_TYPES,
arrayOf("text/*") +
APPLICATION_TEXT_MIME_TYPES,
)
}
.wrapWithChooser(requireContext())
)
}
}
.setCancelButton()
.show()
else ->
importOtherActivityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = "application/*"
putExtra(
Intent.EXTRA_MIME_TYPES,
arrayOf(selectedImportSource.mimeType),
)
addCategory(Intent.CATEGORY_OPENABLE)
}
.wrapWithChooser(requireContext())
)
}
}
.also {
selectedImportSource.documentationUrl?.let<String, Unit> { docUrl ->
it.setNegativeButton(R.string.help) { _, _ ->
val intent =
Intent(Intent.ACTION_VIEW)
.apply { data = Uri.parse(docUrl) }
.wrapWithChooser(requireContext())
startActivity(intent)
}
}
}
.setNeutralButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
.showAndFocus(allowFullSize = true)
}
.setCancelButton()
.show()
}
private fun setupPeriodicBackup(
binding: FragmentSettingsBinding,
value: PeriodicBackup,
backupFolder: String,
preference: PeriodicBackupsPreference,
lastExecutionPreference: LongPreference,
) {
val periodicBackupsEnabled = value.periodInDays > 0 && backupFolder != EMPTY_PATH
binding.PeriodicBackups.setupPeriodicBackup(
periodicBackupsEnabled,
requireContext(),
layoutInflater,
enabled = backupFolder != EMPTY_PATH,
) { enabled ->
if (enabled) {
val periodInDays =
preference.value.periodInDays.let {
if (it >= BACKUP_PERIOD_DAYS_MIN) it else BACKUP_PERIOD_DAYS_MIN
}
val maxBackups =
preference.value.maxBackups.let {
if (it >= BACKUP_MAX_MIN) it else BACKUP_MAX_MIN
}
model.savePreference(
preference,
preference.value.copy(periodInDays = periodInDays, maxBackups = maxBackups),
)
} else {
model.savePreference(preference, preference.value.copy(periodInDays = 0))
}
}
lastExecutionPreference.observe(viewLifecycleOwner) { time ->
binding.PeriodicBackupLastExecution.apply {
if (time != -1L) {
isVisible = true
text =
"${requireContext().getString(R.string.auto_backup_last)}: ${Date(time).toText()}"
} else isVisible = false
}
}
binding.PeriodicBackupsPeriodInDays.setup(
value.periodInDays,
R.string.backup_period_days,
PeriodicBackup.BACKUP_PERIOD_DAYS_MIN,
PeriodicBackup.BACKUP_PERIOD_DAYS_MAX,
requireContext(),
enabled = periodicBackupsEnabled,
) { newValue ->
model.savePreference(preference, preference.value.copy(periodInDays = newValue))
}
binding.PeriodicBackupsMax.setup(
value.maxBackups,
R.string.max_backups,
PeriodicBackup.BACKUP_MAX_MIN,
PeriodicBackup.BACKUP_MAX_MAX,
requireContext(),
enabled = periodicBackupsEnabled,
) { newValue: Int ->
model.savePreference(preference, preference.value.copy(maxBackups = newValue))
}
}
private fun NotallyXPreferences.setupSecurity(binding: FragmentSettingsBinding) {
biometricLock.observe(viewLifecycleOwner) { value ->
binding.BiometricLock.setup(
biometricLock,
value,
requireContext(),
model,
::showEnableBiometricLock,
::showDisableBiometricLock,
::showBiometricsNotSetupDialog,
)
}
backupPassword.observe(viewLifecycleOwner) { value ->
binding.BackupPassword.setupBackupPassword(
backupPassword,
value,
requireContext(),
layoutInflater,
) { newValue ->
model.savePreference(backupPassword, newValue)
}
}
secureFlag.observe(viewLifecycleOwner) { value ->
binding.SecureFlag.setup(secureFlag, value, requireContext(), layoutInflater) { newValue
->
model.savePreference(secureFlag, newValue)
activity?.setEnabledSecureFlag(newValue)
}
}
}
private fun NotallyXPreferences.setupSettings(binding: FragmentSettingsBinding) {
binding.apply {
ImportSettings.setOnClickListener {
showDialog(R.string.import_settings_message, R.string.import_action) { _, _ ->
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT)
.apply {
type = MIME_TYPE_JSON
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX_Settings.json")
}
.wrapWithChooser(requireContext())
importSettingsActivityResultLauncher.launch(intent)
}
}
ExportSettings.setOnClickListener {
showDialog(R.string.export_settings_message, R.string.export) { _, _ ->
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = MIME_TYPE_JSON
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX_Settings.json")
}
.wrapWithChooser(requireContext())
exportSettingsActivityResultLauncher.launch(intent)
}
}
ResetSettings.setOnClickListener {
showDialog(R.string.reset_settings_message, R.string.reset_settings) { _, _ ->
model.resetPreferences { _ -> showToast(R.string.reset_settings_success) }
}
}
dataInPublicFolder.observe(viewLifecycleOwner) { value ->
binding.DataInPublicFolder.setup(
dataInPublicFolder,
value,
requireContext(),
layoutInflater,
R.string.data_in_public_message,
) { enabled ->
if (enabled) {
model.enableDataInPublic()
} else {
model.disableDataInPublic()
}
}
}
AutoSaveAfterIdle.setupAutoSaveIdleTime(autoSaveAfterIdleTime, requireContext()) {
newValue ->
model.savePreference(autoSaveAfterIdleTime, newValue)
}
ClearData.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.clear_data_message)
.setPositiveButton(R.string.delete_all) { _, _ -> model.deleteAll() }
.setCancelButton()
.show()
}
}
model.deletionProgress.setupProgressDialog(this@SettingsFragment, R.string.deleting_files)
}
private fun setupAbout(binding: FragmentSettingsBinding) {
binding.apply {
SendFeedback.setOnClickListener {
val options =
arrayOf(
getString(R.string.report_bug),
getString(R.string.make_feature_request),
getString(R.string.send_feedback),
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.send_feedback)
.setItems(options) { _, which ->
when (which) {
0 -> {
val app = requireContext().applicationContext as Application
val logs = app.getLastExceptionLog()
reportBug(logs)
}
1 ->
requireContext().catchNoBrowserInstalled {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(
"https://github.com/PhilKes/NotallyX/issues/new?labels=enhancement&template=feature_request.md"
),
)
.wrapWithChooser(requireContext())
)
}
2 -> {
val intent =
Intent(Intent.ACTION_SEND)
.apply {
selector =
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
putExtra(
Intent.EXTRA_EMAIL,
arrayOf("notallyx@yahoo.com"),
)
putExtra(Intent.EXTRA_SUBJECT, "NotallyX [Feedback]")
val app =
requireContext().applicationContext as Application
val log = app.getLogFile()
if (log.exists()) {
val uri = app.getUriForFile(log)
putExtra(Intent.EXTRA_STREAM, uri)
}
}
.wrapWithChooser(requireContext())
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
showToast(R.string.install_an_email)
}
}
}
}
.setCancelButton()
.show()
}
Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.philkes.notallyx")
}
SourceCode.setOnClickListener { openLink("https://github.com/PhilKes/NotallyX") }
Libraries.setOnClickListener {
val libraries =
arrayOf(
"Glide",
"Pretty Time",
"SwipeDrawer",
"Work Manager",
"Subsampling Scale ImageView",
"Material Components for Android",
"SQLCipher",
"Zip4J",
"AndroidFastScroll",
"ColorPickerView",
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.libraries)
.setItems(libraries) { _, which ->
when (which) {
0 -> openLink("https://github.com/bumptech/glide")
1 -> openLink("https://github.com/ocpsoft/prettytime")
2 -> openLink("https://leaqi.github.io/SwipeDrawer_en")
3 ->
openLink(
"https://developer.android.com/jetpack/androidx/releases/work"
)
4 ->
openLink(
"https://github.com/davemorrissey/subsampling-scale-image-view"
)
5 ->
openLink(
"https://github.com/material-components/material-components-android"
)
6 -> openLink("https://github.com/sqlcipher/sqlcipher")
7 -> openLink("https://github.com/srikanth-lingala/zip4j")
8 -> openLink("https://github.com/zhanghai/AndroidFastScroll")
9 -> openLink("https://github.com/skydoves/ColorPickerView")
}
}
.setCancelButton()
.show()
}
Donate.setOnClickListener { openLink("https://ko-fi.com/philkes") }
try {
val pInfo =
requireContext().packageManager.getPackageInfo(requireContext().packageName, 0)
val version = pInfo.versionName
VersionText.text = "v$version"
} catch (_: PackageManager.NameNotFoundException) {}
}
}
private fun displayChooseBackupFolderDialog() {
showDialog(R.string.auto_backups_folder_hint, R.string.choose_folder) { _, _ ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).wrapWithChooser(requireContext())
chooseBackupFolderActivityResultLauncher.launch(intent)
}
}
private fun showEnableBiometricLock() {
showBiometricOrPinPrompt(
false,
setupLockActivityResultLauncher,
R.string.enable_lock_title,
R.string.enable_lock_description,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.enableBiometricLock(cipher)
}
val app = (activity?.application as NotallyXApplication)
app.locked.value = false
showToast(R.string.biometrics_setup_success)
},
) {
showBiometricsNotSetupDialog()
}
}
private fun showDisableBiometricLock() {
showBiometricOrPinPrompt(
true,
disableLockActivityResultLauncher,
R.string.disable_lock_title,
R.string.disable_lock_description,
model.preferences.iv.value!!,
onSuccess = { cipher ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
model.disableBiometricLock(cipher)
}
showToast(R.string.biometrics_disable_success)
},
) {}
}
private fun showBiometricsNotSetupDialog() {
showDialog(R.string.biometrics_not_setup, R.string.tap_to_set_up) { _, _ ->
val intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_BIOMETRIC_ENROLL)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Intent(Settings.ACTION_FINGERPRINT_ENROLL)
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
setupLockActivityResultLauncher.launch(intent)
}
}
private fun openLink(link: String) {
val uri = Uri.parse(link)
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(requireContext())
startActivity(intent)
}
private fun askForUriPermissions(uri: Uri) {
chooseBackupFolderActivityResultLauncher.launch(
Intent(ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
}
}
)
}
companion object {
const val EXTRA_SHOW_IMPORT_BACKUPS_FOLDER =
"notallyx.intent.extra.SHOW_IMPORT_BACKUPS_FOLDER"
}
}

View file

@ -1,223 +1,79 @@
package com.philkes.notallyx.presentation.activity.note
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SortedList
import android.os.Build
import android.view.MenuItem
import android.view.inputmethod.InputMethodManager
import com.philkes.notallyx.Preferences
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.hideKeyboardOnFocusedItem
import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showKeyboardOnFocusedItem
import com.philkes.notallyx.presentation.view.note.action.MoreListActions
import com.philkes.notallyx.presentation.view.note.action.MoreListBottomSheet
import com.philkes.notallyx.presentation.view.note.listitem.HighlightText
import com.philkes.notallyx.presentation.view.misc.ListItemSorting
import com.philkes.notallyx.presentation.view.note.listitem.ListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.ListManager
import com.philkes.notallyx.presentation.view.note.listitem.adapter.CheckedListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemAdapter
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemHighlight
import com.philkes.notallyx.presentation.view.note.listitem.adapter.ListItemVH
import com.philkes.notallyx.presentation.view.note.listitem.init
import com.philkes.notallyx.presentation.view.note.listitem.setItems
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemParentSortCallback
import com.philkes.notallyx.presentation.view.note.listitem.sorting.SortedItemsList
import com.philkes.notallyx.presentation.view.note.listitem.splitByChecked
import com.philkes.notallyx.presentation.view.note.listitem.toMutableList
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.autoSortByCheckedEnabled
import com.philkes.notallyx.utils.findAllOccurrences
import com.philkes.notallyx.utils.indices
import com.philkes.notallyx.utils.mapIndexed
import java.util.concurrent.atomic.AtomicInteger
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemNoSortCallback
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedByCheckedCallback
import com.philkes.notallyx.presentation.view.note.listitem.sorting.ListItemSortedList
import com.philkes.notallyx.presentation.view.note.listitem.sorting.toMutableList
import com.philkes.notallyx.presentation.widget.WidgetProvider
import com.philkes.notallyx.utils.add
import com.philkes.notallyx.utils.changehistory.ChangeHistory
import com.philkes.notallyx.utils.setOnNextAction
class EditListActivity : EditActivity(Type.LIST), MoreListActions {
class EditListActivity : EditActivity(Type.LIST) {
private var adapter: ListItemAdapter? = null
private var adapterChecked: CheckedListItemAdapter? = null
private val items: MutableList<ListItem>
get() = adapter!!.items
private lateinit var adapter: ListItemAdapter
private lateinit var items: ListItemSortedList
private var itemsChecked: SortedItemsList? = null
private lateinit var listManager: ListManager
override fun finish() {
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
super.finish()
override suspend fun saveNote() {
model.saveNote(items.toMutableList())
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
}
override fun updateModel() {
super.updateModel()
notallyModel.setItems(items.toMutableList() + (itemsChecked?.toMutableList() ?: listOf()))
}
override fun onSaveInstanceState(outState: Bundle) {
updateModel()
binding.MainListView.focusedChild?.let { focusedChild ->
val viewHolder = binding.MainListView.findContainingViewHolder(focusedChild)
if (viewHolder is ListItemVH) {
val itemPos = binding.MainListView.getChildAdapterPosition(focusedChild)
if (itemPos > -1) {
val (selectionStart, selectionEnd) = viewHolder.getSelection()
outState.apply {
putInt(EXTRA_ITEM_POS, itemPos)
putInt(EXTRA_SELECTION_START, selectionStart)
putInt(EXTRA_SELECTION_END, selectionEnd)
}
}
override fun setupToolbar() {
super.setupToolbar()
binding.Toolbar.menu.apply {
add(
1,
R.string.delete_checked_items,
R.drawable.delete_all,
MenuItem.SHOW_AS_ACTION_IF_ROOM,
) {
listManager.deleteCheckedItems()
}
add(
1,
R.string.check_all_items,
R.drawable.checkbox_fill,
MenuItem.SHOW_AS_ACTION_IF_ROOM,
) {
listManager.changeCheckedForAll(true)
}
add(
1,
R.string.uncheck_all_items,
R.drawable.checkbox,
MenuItem.SHOW_AS_ACTION_IF_ROOM,
) {
listManager.changeCheckedForAll(false)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setGroupDividerEnabled(true)
}
}
super.onSaveInstanceState(outState)
}
override fun toggleCanEdit(mode: NoteViewMode) {
super.toggleCanEdit(mode)
when (mode) {
NoteViewMode.EDIT -> binding.MainListView.showKeyboardOnFocusedItem()
NoteViewMode.READ_ONLY -> binding.MainListView.hideKeyboardOnFocusedItem()
}
adapter?.viewMode = mode
adapterChecked?.viewMode = mode
binding.AddItem.visibility =
when (mode) {
NoteViewMode.EDIT -> View.VISIBLE
else -> View.GONE
}
}
override fun deleteChecked() {
listManager.deleteCheckedItems()
}
override fun checkAll() {
listManager.changeCheckedForAll(true)
}
override fun uncheckAll() {
listManager.changeCheckedForAll(false)
}
override fun initBottomMenu() {
super.initBottomMenu()
binding.BottomAppBarRight.apply {
removeAllViews()
addToggleViewMode()
addIconButton(R.string.tap_for_more_options, R.drawable.more_vert, marginStart = 0) {
MoreListBottomSheet(
this@EditListActivity,
createNoteTypeActions() + createFolderActions(),
colorInt,
)
.show(supportFragmentManager, MoreListBottomSheet.TAG)
}
}
setBottomAppBarColor(colorInt)
}
private fun SortedList<ListItem>.highlightSearch(
search: String,
adapter: HighlightText?,
resultPosCounter: AtomicInteger,
alreadyNotifiedItemPos: MutableSet<Int>,
): Int {
return mapIndexed { idx, item ->
val occurrences = item.body.findAllOccurrences(search)
occurrences.onEach { (startIdx, endIdx) ->
adapter?.highlightText(
ListItemHighlight(
idx,
resultPosCounter.getAndIncrement(),
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
}
private fun List<ListItem>.highlightSearch(
search: String,
adapter: ListItemAdapter?,
resultPosCounter: AtomicInteger,
alreadyNotifiedItemPos: MutableSet<Int>,
): Int {
return mapIndexed { idx, item ->
val occurrences = item.body.findAllOccurrences(search)
occurrences.onEach { (startIdx, endIdx) ->
adapter?.highlightText(
ListItemHighlight(
idx,
resultPosCounter.getAndIncrement(),
startIdx,
endIdx,
false,
)
)
}
if (occurrences.isNotEmpty()) {
alreadyNotifiedItemPos.add(idx)
}
occurrences.size
}
.sum()
}
override fun highlightSearchResults(search: String): Int {
val resultPosCounter = AtomicInteger(0)
val alreadyNotifiedItemPos = mutableSetOf<Int>()
adapter?.clearHighlights()
adapterChecked?.clearHighlights()
val amount =
items.highlightSearch(search, adapter, resultPosCounter, alreadyNotifiedItemPos) +
(itemsChecked?.highlightSearch(
search,
adapterChecked,
resultPosCounter,
alreadyNotifiedItemPos,
) ?: 0)
items.indices
.filter { !alreadyNotifiedItemPos.contains(it) }
.forEach { adapter?.notifyItemChanged(it) }
itemsChecked
?.indices
?.filter { !alreadyNotifiedItemPos.contains(it) }
?.forEach { adapter?.notifyItemChanged(it) }
return amount
}
override fun selectSearchResult(resultPos: Int) {
var selectedItemPos = adapter!!.selectHighlight(resultPos)
if (selectedItemPos == -1 && adapterChecked != null) {
selectedItemPos = adapterChecked!!.selectHighlight(resultPos)
if (selectedItemPos != -1) {
binding.CheckedListView.scrollToItemPosition(selectedItemPos)
}
} else if (selectedItemPos != -1) {
binding.MainListView.scrollToItemPosition(selectedItemPos)
}
}
private fun RecyclerView.scrollToItemPosition(position: Int) {
post {
findViewHolderForAdapterPosition(position)?.itemView?.let {
binding.ScrollView.scrollTo(0, top + it.top)
}
override fun initActionManager(undo: MenuItem, redo: MenuItem) {
changeHistory = ChangeHistory {
undo.isEnabled = changeHistory.canUndo()
redo.isEnabled = changeHistory.canRedo()
}
}
override fun configureUI() {
binding.EnterTitle.setOnNextAction { listManager.moveFocusToNext(-1) }
if (notallyModel.isNewNote || notallyModel.items.isEmpty()) {
if (model.isNewNote || model.items.isEmpty()) {
listManager.add(pushChange = false)
}
}
@ -227,92 +83,36 @@ class EditListActivity : EditActivity(Type.LIST), MoreListActions {
binding.AddItem.setOnClickListener { listManager.add() }
}
override fun setStateFromModel(savedInstanceState: Bundle?) {
super.setStateFromModel(savedInstanceState)
override fun setStateFromModel() {
super.setStateFromModel()
val elevation = resources.displayMetrics.density * 2
listManager =
ListManager(
binding.MainListView,
binding.RecyclerView,
changeHistory,
preferences,
inputMethodManager,
{
if (isInSearchMode()) {
endSearch()
}
},
) { _ ->
if (isInSearchMode() && search.results.value > 0) {
updateSearchResults(search.query)
}
}
getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager,
)
adapter =
ListItemAdapter(
colorInt,
notallyModel.textSize,
model.textSize,
elevation,
NotallyXPreferences.getInstance(application),
Preferences.getInstance(application),
listManager,
false,
binding.ScrollView,
)
val initializedItems = notallyModel.items.init(true)
if (preferences.autoSortByCheckedEnabled) {
val (checkedItems, uncheckedItems) = initializedItems.splitByChecked()
adapter?.submitList(uncheckedItems.toMutableList())
adapterChecked =
CheckedListItemAdapter(
colorInt,
notallyModel.textSize,
elevation,
NotallyXPreferences.getInstance(application),
listManager,
true,
binding.ScrollView,
)
itemsChecked =
SortedItemsList(ListItemParentSortCallback(adapterChecked!!)).apply {
setItems(checkedItems.toMutableList())
}
adapterChecked?.setList(itemsChecked!!)
binding.CheckedListView.adapter = adapterChecked
} else {
adapter?.submitList(initializedItems.toMutableList())
}
listManager.init(adapter!!, itemsChecked, adapterChecked)
binding.MainListView.adapter = adapter
savedInstanceState?.let {
val itemPos = it.getInt(EXTRA_ITEM_POS, -1)
if (itemPos > -1) {
binding.MainListView.apply {
post {
scrollToPosition(itemPos)
val viewHolder = findViewHolderForLayoutPosition(itemPos)
if (viewHolder is ListItemVH) {
val selectionStart = it.getInt(EXTRA_SELECTION_START, -1)
val selectionEnd = it.getInt(EXTRA_SELECTION_END, -1)
viewHolder.focusEditText(
selectionStart,
selectionEnd,
inputMethodManager,
)
}
}
}
val sortCallback =
when (preferences.listItemSorting.value) {
ListItemSorting.autoSortByChecked -> ListItemSortedByCheckedCallback(adapter)
else -> ListItemNoSortCallback(adapter)
}
items = ListItemSortedList(sortCallback)
if (sortCallback is ListItemSortedByCheckedCallback) {
sortCallback.setList(items)
}
}
override fun setColor() {
super.setColor()
adapter?.setBackgroundColor(colorInt)
adapterChecked?.setBackgroundColor(colorInt)
}
companion object {
private const val EXTRA_ITEM_POS = "notallyx.intent.extra.ITEM_POS"
private const val EXTRA_SELECTION_START = "notallyx.intent.extra.EXTRA_SELECTION_START"
private const val EXTRA_SELECTION_END = "notallyx.intent.extra.EXTRA_SELECTION_END"
items.init(model.items)
adapter.setList(items)
binding.RecyclerView.adapter = adapter
listManager.adapter = adapter
listManager.initList(items)
}
}

View file

@ -1,441 +1,213 @@
package com.philkes.notallyx.presentation.activity.note
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import android.text.style.URLSpan
import android.text.style.UnderlineSpan
import android.util.Patterns
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.LinearLayout
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
import android.widget.EditText
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.NoteViewMode
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.createNoteUrl
import com.philkes.notallyx.data.model.getNoteIdFromUrl
import com.philkes.notallyx.data.model.getNoteTypeFromUrl
import com.philkes.notallyx.data.model.isNoteUrl
import com.philkes.notallyx.databinding.BottomTextFormattingMenuBinding
import com.philkes.notallyx.databinding.RecyclerToggleBinding
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_EXCLUDE_NOTE_ID
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_ID
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TITLE
import com.philkes.notallyx.presentation.activity.note.PickNoteActivity.Companion.EXTRA_PICKED_NOTE_TYPE
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.addIconButton
import com.philkes.notallyx.presentation.createBoldSpan
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.hideKeyboard
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.setOnNextAction
import com.philkes.notallyx.presentation.showKeyboard
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.note.TextFormattingAdapter
import com.philkes.notallyx.presentation.view.note.action.AddNoteActions
import com.philkes.notallyx.presentation.view.note.action.AddNoteBottomSheet
import com.philkes.notallyx.databinding.UpdateLinkDialogBinding
import com.philkes.notallyx.utils.LinkMovementMethod
import com.philkes.notallyx.utils.copyToClipBoard
import com.philkes.notallyx.utils.findAllOccurrences
import com.philkes.notallyx.utils.wrapWithChooser
import com.philkes.notallyx.utils.add
import com.philkes.notallyx.utils.changehistory.EditTextChange
import com.philkes.notallyx.utils.clone
import com.philkes.notallyx.utils.createTextWatcherWithHistory
import com.philkes.notallyx.utils.removeSelectionFromSpan
import com.philkes.notallyx.utils.setOnNextAction
import com.philkes.notallyx.utils.showKeyboard
class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
class EditNoteActivity : EditActivity(Type.NOTE) {
private lateinit var selectedSpan: URLSpan
private lateinit var pickNoteNewActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var pickNoteUpdateActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var textFormatMenu: View
private var textFormattingAdapter: TextFormattingAdapter? = null
private var searchResultIndices: List<Pair<Int, Int>>? = null
private lateinit var enterBodyTextWatcher: TextWatcher
override fun configureUI() {
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
if (notallyModel.isNewNote) {
binding.EnterBody.requestFocus()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupActivityResultLaunchers()
}
override fun toggleCanEdit(mode: NoteViewMode) {
super.toggleCanEdit(mode)
textFormatMenu.isVisible = mode == NoteViewMode.EDIT
when {
mode == NoteViewMode.EDIT -> showKeyboard(binding.EnterBody)
binding.EnterBody.isFocused -> hideKeyboard(binding.EnterBody)
}
binding.EnterBody.setCanEdit(mode == NoteViewMode.EDIT)
setupEditor()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.apply {
putInt(EXTRA_SELECTION_START, binding.EnterBody.selectionStart)
putInt(EXTRA_SELECTION_END, binding.EnterBody.selectionEnd)
}
}
private fun setupActivityResultLaunchers() {
pickNoteNewActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
try {
val (title, url, emptyTitle) = result.data.getPickedNoteData()
if (emptyTitle) {
binding.EnterBody.showAddLinkDialog(
this,
presetDisplayText = title,
presetUrl = url,
isNewUnnamedLink = true,
)
} else {
binding.EnterBody.addSpans(title, listOf(UnderlineSpan(), URLSpan(url)))
}
} catch (_: IllegalArgumentException) {}
}
}
pickNoteUpdateActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
try {
val (title, url, emptyTitle) = result.data.getPickedNoteData()
val newSpan = URLSpan(url)
binding.EnterBody.updateSpan(selectedSpan, newSpan, title)
if (emptyTitle) {
binding.EnterBody.showEditDialog(newSpan, isNewUnnamedLink = true)
}
} catch (_: IllegalArgumentException) {}
}
}
}
override fun highlightSearchResults(search: String): Int {
binding.EnterBody.clearHighlights()
if (search.isEmpty()) {
return 0
}
searchResultIndices =
notallyModel.body.toString().findAllOccurrences(search).onEach { (startIdx, endIdx) ->
binding.EnterBody.highlight(startIdx, endIdx, false)
}
return searchResultIndices!!.size
}
override fun selectSearchResult(resultPos: Int) {
if (resultPos < 0) {
binding.EnterBody.unselectHighlight()
return
}
searchResultIndices?.get(resultPos)?.let { (startIdx, endIdx) ->
val selectedLineTop = binding.EnterBody.highlight(startIdx, endIdx, true)
selectedLineTop?.let { binding.ScrollView.scrollTo(0, it) }
if (model.isNewNote) {
binding.EnterBody.requestFocus()
}
}
override fun setupListeners() {
super.setupListeners()
binding.EnterBody.initHistory(changeHistory) { text ->
val textChanged = !notallyModel.body.toString().contentEquals(text)
notallyModel.body = text
if (textChanged) {
updateSearchResults(search.query)
enterBodyTextWatcher = run {
binding.EnterBody.createTextWatcherWithHistory(
changeHistory,
{ text, start, count ->
if (count > 1) {
val changedText = text.substring(start, start + count)
if (Patterns.WEB_URL.matcher(changedText).matches()) {
binding.EnterBody.text?.setSpan(
URLSpan(changedText),
start,
start + count,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
}
},
) { text: Editable ->
model.body = text.clone()
}
}
binding.EnterBody.addTextChangedListener(enterBodyTextWatcher)
}
override fun setStateFromModel(savedInstanceState: Bundle?) {
super.setStateFromModel(savedInstanceState)
override fun setStateFromModel() {
super.setStateFromModel()
updateEditText()
savedInstanceState?.let {
val selectionStart = it.getInt(EXTRA_SELECTION_START, -1)
val selectionEnd = it.getInt(EXTRA_SELECTION_END, -1)
if (selectionStart > -1) {
binding.EnterBody.focusAndSelect(selectionStart, selectionEnd)
}
}
}
private fun updateEditText() {
binding.EnterBody.text = notallyModel.body
binding.EnterBody.apply {
removeTextChangedListener(enterBodyTextWatcher)
text = model.body
addTextChangedListener(enterBodyTextWatcher)
}
}
private fun setupEditor() {
setupMovementMethod()
binding.EnterBody.customSelectionActionModeCallback =
if (canEdit) {
object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) = false
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) = false
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
binding.EnterBody.isActionModeOn = true
// Try block is there because this will crash on MiUI as Xiaomi has a broken
// ActionMode implementation
try {
menu?.apply {
add(
R.string.link,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.showAddLinkDialog(
this@EditNoteActivity,
mode = mode,
)
}
add(
R.string.bold,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(createBoldSpan())
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
binding.EnterBody.isActionModeOn = true
// Try block is there because this will crash on MiUI as Xiaomi has a broken
// ActionMode implementation
try {
if (menu != null) {
menu.apply {
add(R.string.bold, 0) {
applySpan(StyleSpan(Typeface.BOLD))
mode?.finish()
}
add(
R.string.italic,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(StyleSpan(Typeface.ITALIC))
mode?.finish()
}
add(
R.string.monospace,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(TypefaceSpan("monospace"))
mode?.finish()
}
add(
R.string.strikethrough,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.applySpan(StrikethroughSpan())
mode?.finish()
}
add(
R.string.clear_formatting,
0,
showAsAction = MenuItem.SHOW_AS_ACTION_NEVER,
) {
binding.EnterBody.clearFormatting()
mode?.finish()
}
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
}
}
} else null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.EnterBody.customInsertionActionModeCallback =
if (canEdit) {
object : ActionMode.Callback {
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) = false
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
binding.EnterBody.isActionModeOn = true
// Try block is there because this will crash on MiUI as Xiaomi has a
// broken
// ActionMode implementation
try {
menu?.apply {
add(
R.string.link_note,
0,
order = Menu.CATEGORY_CONTAINER + 1,
add(R.string.link, 0) {
val clipBoardText = getClipboardText()
if (
clipBoardText != null &&
Patterns.WEB_URL.matcher(clipBoardText).matches()
) {
linkNote(pickNoteNewActivityResultLauncher)
mode?.finish()
applySpan(URLSpan(clipBoardText.toString()))
} else {
Toast.makeText(
this@EditNoteActivity,
R.string.invalid_link,
Toast.LENGTH_LONG,
)
.show()
}
mode?.finish()
}
add(R.string.italic, 0) {
applySpan(StyleSpan(Typeface.ITALIC))
mode?.finish()
}
add(R.string.monospace, 0) {
applySpan(TypefaceSpan("monospace"))
mode?.finish()
}
add(R.string.strikethrough, 0) {
applySpan(StrikethroughSpan())
mode?.finish()
}
add(R.string.clear_formatting, 0) {
clearFormatting()
mode?.finish()
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
}
}
} else null
}
if (canEdit) {
binding.EnterBody.setOnSelectionChange { selStart, selEnd ->
if (selEnd - selStart > 0) {
if (!textFormatMenu.isEnabled) {
initBottomTextFormattingMenu()
}
textFormatMenu.isEnabled = true
textFormattingAdapter?.updateTextFormattingToggles(selStart, selEnd)
} else {
if (textFormatMenu.isEnabled) {
initBottomMenu()
}
textFormatMenu.isEnabled = false
fun getClipboardText(): CharSequence? {
val clipboard = baseContext.getSystemService(ClipboardManager::class.java)!!
val clipData = clipboard.primaryClip!!
return if (clipData.itemCount > 0) clipData.getItemAt(0)!!.text else null
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
model.body = binding.EnterBody.text!!.clone()
}
}
} else {
binding.EnterBody.setOnSelectionChange { _, _ -> }
}
binding.ContentLayout.setOnClickListener {
binding.EnterBody.apply {
requestFocus()
if (canEdit) {
setSelection(length())
showKeyboard(this)
}
setSelection(text!!.length)
showKeyboard(this)
}
}
}
override fun initBottomMenu() {
super.initBottomMenu()
binding.BottomAppBarCenter.visibility = VISIBLE
binding.BottomAppBarLeft.apply {
removeAllViews()
addIconButton(R.string.add_item, R.drawable.add, marginStart = 0) {
AddNoteBottomSheet(this@EditNoteActivity, colorInt)
.show(supportFragmentManager, AddNoteBottomSheet.TAG)
}
updateLayoutParams<ConstraintLayout.LayoutParams> { endToStart = -1 }
textFormatMenu =
addIconButton(R.string.edit, R.drawable.text_format) {
initBottomTextFormattingMenu()
}
.apply { isEnabled = binding.EnterBody.isActionModeOn }
}
setBottomAppBarColor(colorInt)
}
private fun initBottomTextFormattingMenu() {
binding.BottomAppBarCenter.visibility = GONE
val extractColor = colorInt
binding.BottomAppBarRight.apply {
removeAllViews()
addView(
RecyclerToggleBinding.inflate(layoutInflater, this, false).root.apply {
setIconResource(R.drawable.close)
contentDescription = context.getString(R.string.cancel)
setOnClickListener { initBottomMenu() }
updateLayoutParams<LinearLayout.LayoutParams> {
marginEnd = 0
marginStart = 10.dp
}
setControlsContrastColorForAllViews(extractColor)
setBackgroundColor(0)
}
)
}
binding.BottomAppBarLeft.apply {
removeAllViews()
updateLayoutParams<ConstraintLayout.LayoutParams> {
endToStart = R.id.BottomAppBarRight
}
requestLayout()
val layout = BottomTextFormattingMenuBinding.inflate(layoutInflater, this, false)
layout.MainListView.apply {
textFormattingAdapter =
TextFormattingAdapter(this@EditNoteActivity, binding.EnterBody, colorInt)
adapter = textFormattingAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
addView(layout.root)
}
}
override fun linkNote() {
linkNote(pickNoteNewActivityResultLauncher)
}
fun linkNote(activityResultLauncher: ActivityResultLauncher<Intent>) {
val intent =
Intent(this, PickNoteActivity::class.java).apply {
putExtra(EXTRA_EXCLUDE_NOTE_ID, notallyModel.id)
}
activityResultLauncher.launch(intent)
}
private fun setupMovementMethod() {
val items =
arrayOf(
getString(R.string.copy),
getString(R.string.edit),
getString(R.string.open_link),
)
val movementMethod = LinkMovementMethod { span ->
val items =
if (span.url.isNoteUrl()) {
if (canEdit) {
arrayOf(
getString(R.string.open_note),
getString(R.string.remove_link),
getString(R.string.change_note),
getString(R.string.edit),
)
} else arrayOf(getString(R.string.open_note))
} else {
if (canEdit) {
arrayOf(
getString(R.string.open_link),
getString(R.string.copy),
getString(R.string.remove_link),
getString(R.string.edit),
)
} else arrayOf(getString(R.string.open_link), getString(R.string.copy))
}
MaterialAlertDialogBuilder(this)
.setTitle(
if (span.url.isNoteUrl())
"${getString(R.string.note)}: ${
binding.EnterBody.getSpanText(span)
}"
else span.url
)
.setTitle(span.url)
.setItems(items) { _, which ->
when (which) {
0 -> openLink(span)
0 -> {
val clipboard: ClipboardManager =
getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("label", span.url)
clipboard.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_link, Toast.LENGTH_LONG).show()
}
1 ->
if (span.url.isNoteUrl()) {
removeLink(span)
} else copyLink(span)
2 ->
if (span.url.isNoteUrl()) {
changeNoteLink(span)
} else removeLink(span)
3 -> editLink(span)
showUrlInputDialog(span.url) { newUrl ->
binding.EnterBody.changeFormatting { _, _, text ->
text.replaceUrlSpan(span, newUrl)
model.body = text.clone()
Toast.makeText(this, R.string.updated_link, Toast.LENGTH_LONG)
.show()
}
}
2 -> {
if (span.url != null) {
val uri = Uri.parse(span.url)
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
startActivity(intent)
} catch (exception: Exception) {
Toast.makeText(this, R.string.cant_open_link, Toast.LENGTH_LONG)
.show()
}
}
}
}
}
.show()
@ -443,80 +215,84 @@ class EditNoteActivity : EditActivity(Type.NOTE), AddNoteActions {
binding.EnterBody.movementMethod = movementMethod
}
private fun openLink(span: URLSpan) {
span.url?.let {
if (it.isNoteUrl()) {
span.navigateToNote()
} else {
openLink(span.url)
private fun Editable.replaceUrlSpan(existingSpan: URLSpan, newUrl: String) {
val start = this.getSpanStart(existingSpan)
val end = this.getSpanEnd(existingSpan)
if (start >= 0 && end >= 0) {
this.removeSpan(existingSpan)
val newSpan = URLSpan(newUrl)
this.setSpan(newSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun showUrlInputDialog(urlBefore: String, onSuccess: (newUrl: String) -> Unit) {
val layout = UpdateLinkDialogBinding.inflate(layoutInflater)
layout.InputText.setText(urlBefore)
MaterialAlertDialogBuilder(this)
.setView(layout.root)
.setTitle(R.string.edit_link)
.setPositiveButton(R.string.save) { _, _ ->
val userInput = layout.InputText.text.toString()
onSuccess.invoke(userInput)
}
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
.show()
layout.InputText.requestFocus()
showKeyboard(layout.InputText)
}
private fun clearFormatting() {
binding.EnterBody.changeFormatting { start, end, text ->
text.removeSelectionFromSpan(start, end)
}
}
private fun editLink(span: URLSpan) {
binding.EnterBody.showEditDialog(span)
}
private fun changeNoteLink(span: URLSpan) {
selectedSpan = span
linkNote(pickNoteUpdateActivityResultLauncher)
}
private fun copyLink(span: URLSpan) {
copyToClipBoard(span.url)
showToast(R.string.copied_link)
}
private fun removeLink(span: URLSpan) {
binding.EnterBody.removeSpanWithHistory(
span,
span.url.isNoteUrl() || span.url == binding.EnterBody.getSpanText(span),
)
}
private fun openLink(url: String) {
val uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, uri).wrapWithChooser(this)
try {
startActivity(intent)
} catch (exception: Exception) {
showToast(R.string.cant_open_link)
private fun applySpan(
span: Any,
start: Int = binding.EnterBody.selectionStart,
end: Int = binding.EnterBody.selectionEnd,
) {
binding.EnterBody.changeFormatting(start, end) { textStart, textEnd, text ->
text.setSpan(span, textStart, textEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun URLSpan.navigateToNote() {
val noteId = url.getNoteIdFromUrl()
val noteType = url.getNoteTypeFromUrl()
when (noteType) {
Type.NOTE -> goToActivity(EditNoteActivity::class.java, noteId)
Type.LIST -> goToActivity(EditListActivity::class.java, noteId)
private fun EditText.changeFormatting(
start: Int = selectionStart,
end: Int = selectionEnd,
change: (start: Int, end: Int, text: Editable) -> Unit,
) {
ifBothNotNullAndInvalid(start, end) { textStart, textEnd ->
val textBefore = text!!.clone()
change(textStart, textEnd, text)
val textAfter = text!!.clone()
changeHistory.push(
EditTextChange(this, textBefore, textAfter, enterBodyTextWatcher) { text ->
model.body = text.clone()
}
)
}
}
private fun goToActivity(activity: Class<out Activity>, noteId: Long) {
val intent = Intent(this, activity)
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
startActivity(intent)
}
private fun Intent?.getPickedNoteData(): Triple<String, String, Boolean> {
val noteId = this?.getLongExtra(EXTRA_PICKED_NOTE_ID, -1L)!!
if (noteId == -1L) {
throw IllegalArgumentException("Invalid note picked!")
private fun ifBothNotNullAndInvalid(
start: Int?,
end: Int?,
function: (start: Int, end: Int) -> Unit,
) {
if (start != null && start != -1 && end != null && end != -1) {
function.invoke(start, end)
}
var emptyTitle = false
val noteTitle =
this.getStringExtra(EXTRA_PICKED_NOTE_TITLE)!!.ifEmpty {
emptyTitle = true
this@EditNoteActivity.getString(R.string.note)
}
val noteType = Type.valueOf(this.getStringExtra(EXTRA_PICKED_NOTE_TYPE)!!)
val noteUrl = noteId.createNoteUrl(noteType)
return Triple(noteTitle, noteUrl, emptyTitle)
}
companion object {
private const val EXTRA_SELECTION_START = "notallyx.intent.extra.EXTRA_SELECTION_START"
private const val EXTRA_SELECTION_END = "notallyx.intent.extra.EXTRA_SELECTION_END"
fun getURLFrom(text: String): String {
return when {
text.matches(Patterns.PHONE.toRegex()) -> "tel:$text"
text.matches(Patterns.EMAIL_ADDRESS.toRegex()) -> "mailto:$text"
text.matches(Patterns.DOMAIN_NAME.toRegex()) -> "http://$text"
else -> text
}
}
}
}

View file

@ -1,113 +0,0 @@
package com.philkes.notallyx.presentation.activity.note
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Header
import com.philkes.notallyx.databinding.ActivityPickNoteBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.view.main.BaseNoteAdapter
import com.philkes.notallyx.presentation.view.main.BaseNoteVHPreferences
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.viewmodel.BaseNoteModel
import com.philkes.notallyx.presentation.viewmodel.preference.NotallyXPreferences
import com.philkes.notallyx.presentation.viewmodel.preference.NotesView
import com.philkes.notallyx.utils.getExternalImagesDirectory
import java.util.Collections
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
open class PickNoteActivity : LockedActivity<ActivityPickNoteBinding>(), ItemListener {
protected lateinit var adapter: BaseNoteAdapter
private val excludedNoteId by lazy { intent.getLongExtra(EXTRA_EXCLUDE_NOTE_ID, -1L) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPickNoteBinding.inflate(layoutInflater)
setContentView(binding.root)
val result = Intent()
setResult(RESULT_CANCELED, result)
val preferences = NotallyXPreferences.getInstance(application)
adapter =
with(preferences) {
BaseNoteAdapter(
Collections.emptySet(),
dateFormat.value,
notesSorting.value,
BaseNoteVHPreferences(
textSize.value,
maxItems.value,
maxLines.value,
maxTitle.value,
labelTagsHiddenInOverview.value,
imagesHiddenInOverview.value,
),
application.getExternalImagesDirectory(),
this@PickNoteActivity,
)
}
binding.MainListView.apply {
adapter = this@PickNoteActivity.adapter
setHasFixedSize(true)
layoutManager =
if (preferences.notesView.value == NotesView.GRID) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(this@PickNoteActivity)
}
val database = NotallyDatabase.getDatabase(application)
val pinned = Header(getString(R.string.pinned))
val others = Header(getString(R.string.others))
val archived = Header(getString(R.string.archived))
database.observe(this) {
lifecycleScope.launch {
val notes =
withContext(Dispatchers.IO) {
val raw =
it.getBaseNoteDao().getAllNotes().filter { it.id != excludedNoteId }
BaseNoteModel.transform(raw, pinned, others, archived)
}
adapter.submitList(notes)
binding.EmptyView.visibility =
if (notes.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE
}
}
}
override fun onClick(position: Int) {
if (position != -1) {
val note = (adapter.getItem(position) as BaseNote)
val success = Intent()
success.putExtra(EXTRA_PICKED_NOTE_ID, note.id)
success.putExtra(EXTRA_PICKED_NOTE_TITLE, note.title)
success.putExtra(EXTRA_PICKED_NOTE_TYPE, note.type.name)
setResult(RESULT_OK, success)
finish()
}
}
override fun onLongClick(position: Int) {}
companion object {
const val EXTRA_EXCLUDE_NOTE_ID = "notallyx.intent.extra.EXCLUDE_NOTE_ID"
const val EXTRA_PICKED_NOTE_ID = "notallyx.intent.extra.PICKED_NOTE_ID"
const val EXTRA_PICKED_NOTE_TITLE = "notallyx.intent.extra.PICKED_NOTE_TITLE"
const val EXTRA_PICKED_NOTE_TYPE = "notallyx.intent.extra.PICKED_NOTE_TYPE"
}
}

View file

@ -7,22 +7,17 @@ import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.IntentCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.databinding.ActivityPlayAudioBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.utils.IO
import com.philkes.notallyx.utils.add
import com.philkes.notallyx.utils.audio.AudioPlayService
import com.philkes.notallyx.utils.audio.LocalBinder
import com.philkes.notallyx.utils.getExternalAudioDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.wrapWithChooser
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@ -35,7 +30,6 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
private var service: AudioPlayService? = null
private lateinit var connection: ServiceConnection
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var audio: Audio
@ -44,10 +38,7 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
binding = ActivityPlayAudioBinding.inflate(layoutInflater)
setContentView(binding.root)
audio =
requireNotNull(
intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_AUDIO, Audio::class.java) }
)
audio = requireNotNull(intent.getParcelableExtra(AUDIO))
binding.AudioControlView.setDuration(audio.duration)
val intent = Intent(this, AudioPlayService::class.java)
@ -71,20 +62,9 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
binding.Play.setOnClickListener { service?.play() }
audio.duration?.let {
binding.AudioControlView.onSeekComplete = { milliseconds ->
service?.seek(milliseconds)
}
}
binding.AudioControlView.onSeekComplete = { milliseconds -> service?.seek(milliseconds) }
setupToolbar(binding)
exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> writeAudioToUri(uri) }
}
}
}
override fun onDestroy() {
@ -100,6 +80,13 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_EXPORT_FILE && resultCode == RESULT_OK) {
data?.data?.let { uri -> writeAudioToUri(uri) }
}
}
private fun setupToolbar(binding: ActivityPlayAudioBinding) {
binding.Toolbar.setNavigationOnClickListener { onBackPressed() }
@ -111,28 +98,29 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
}
private fun share() {
val audioRoot = application.getExternalAudioDirectory()
val audioRoot = IO.getExternalAudioDirectory(application)
val file = if (audioRoot != null) File(audioRoot, audio.name) else null
if (file != null && file.exists()) {
val uri = getUriForFile(file)
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
val intent =
Intent(Intent.ACTION_SEND)
.apply {
type = "audio/mp4"
putExtra(Intent.EXTRA_STREAM, uri)
}
.wrapWithChooser(this@PlayAudioActivity)
startActivity(intent)
Intent(Intent.ACTION_SEND).apply {
type = "audio/mp4"
putExtra(Intent.EXTRA_STREAM, uri)
}
val chooser = Intent.createChooser(intent, null)
startActivity(chooser)
}
}
private fun delete() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_audio_recording_forever)
.setCancelButton()
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
val intent = Intent()
intent.putExtra(EXTRA_AUDIO, audio)
intent.putExtra(AUDIO, audio)
setResult(RESULT_OK, intent)
finish()
}
@ -140,29 +128,27 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
}
private fun saveToDevice() {
val audioRoot = application.getExternalAudioDirectory()
val audioRoot = IO.getExternalAudioDirectory(application)
val file = if (audioRoot != null) File(audioRoot, audio.name) else null
if (file != null && file.exists()) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = "audio/mp4"
addCategory(Intent.CATEGORY_OPENABLE)
}
.wrapWithChooser(this)
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "audio/mp4"
addCategory(Intent.CATEGORY_OPENABLE)
}
val formatter = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.SHORT)
val title = formatter.format(audio.timestamp)
intent.putExtra(Intent.EXTRA_TITLE, title)
exportFileActivityResultLauncher.launch(intent)
startActivityForResult(intent, REQUEST_EXPORT_FILE)
}
}
private fun writeAudioToUri(uri: Uri) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val audioRoot = application.getExternalAudioDirectory()
val audioRoot = IO.getExternalAudioDirectory(application)
val file = if (audioRoot != null) File(audioRoot, audio.name) else null
if (file != null && file.exists()) {
val output = contentResolver.openOutputStream(uri) as FileOutputStream
@ -203,6 +189,7 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
}
companion object {
const val EXTRA_AUDIO = "notallyx.intent.extra.AUDIO"
const val AUDIO = "AUDIO"
private const val REQUEST_EXPORT_FILE = 50
}
}

View file

@ -5,25 +5,21 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import androidx.activity.OnBackPressedCallback
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.ActivityRecordAudioBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.utils.IO
import com.philkes.notallyx.utils.audio.AudioRecordService
import com.philkes.notallyx.utils.audio.LocalBinder
import com.philkes.notallyx.utils.audio.Status
import com.philkes.notallyx.utils.getTempAudioFile
@RequiresApi(24)
class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
private var service: AudioRecordService? = null
private lateinit var connection: ServiceConnection
private lateinit var serviceStatusObserver: Observer<Status>
private lateinit var cancelRecordCallback: OnBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -37,7 +33,7 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
service = (binder as LocalBinder<AudioRecordService>).getService()
service?.status?.observe(this@RecordAudioActivity, serviceStatusObserver)
updateUI(binding, requireNotNull(service))
}
override fun onServiceDisconnected(name: ComponentName?) {}
@ -48,11 +44,12 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
binding.Main.setOnClickListener {
val service = this.service
if (service != null) {
when (service.status.value) {
when (service.status) {
Status.PAUSED -> service.resume()
Status.READY -> service.start()
Status.RECORDING -> service.pause()
}
updateUI(binding, service)
}
}
@ -63,30 +60,13 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
}
}
binding.Toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
cancelRecordCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
MaterialAlertDialogBuilder(this@RecordAudioActivity)
.setMessage(R.string.save_recording)
.setPositiveButton(R.string.save) { _, _ -> stopRecording(service!!) }
.setNegativeButton(R.string.discard) { _, _ -> discard(service!!) }
.show()
}
}
onBackPressedDispatcher.addCallback(cancelRecordCallback)
serviceStatusObserver = Observer { status ->
updateUI(binding, service!!)
cancelRecordCallback.isEnabled = status != Status.READY
}
binding.Toolbar.setNavigationOnClickListener { onBackPressed() }
}
override fun onDestroy() {
super.onDestroy()
service?.let {
if (service != null) {
unbindService(connection)
it.status.removeObserver(serviceStatusObserver)
service = null
}
if (isFinishing) {
@ -95,9 +75,22 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
}
}
override fun onBackPressed() {
val service = this.service
if (service != null) {
if (service.status != Status.READY) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.save_recording)
.setPositiveButton(R.string.save) { _, _ -> stopRecording(service) }
.setNegativeButton(R.string.discard) { _, _ -> discard(service) }
.show()
} else super.onBackPressed()
} else super.onBackPressed()
}
private fun discard(service: AudioRecordService) {
service.stop()
getTempAudioFile().delete()
IO.getTempAudioFile(this).delete()
finish()
}
@ -109,7 +102,7 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
private fun updateUI(binding: ActivityRecordAudioBinding, service: AudioRecordService) {
binding.Timer.base = service.getBase()
when (service.status.value) {
when (service.status) {
Status.READY -> {
binding.Stop.isEnabled = false
binding.Main.setText(R.string.start)

View file

@ -3,6 +3,8 @@ package com.philkes.notallyx.presentation.activity.note
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -11,14 +13,14 @@ import com.philkes.notallyx.data.model.Label
import com.philkes.notallyx.databinding.ActivityLabelBinding
import com.philkes.notallyx.databinding.DialogInputBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.showToast
import com.philkes.notallyx.presentation.view.main.label.SelectableLabelAdapter
import com.philkes.notallyx.presentation.view.main.SelectableLabelAdapter
import com.philkes.notallyx.presentation.viewmodel.LabelModel
import com.philkes.notallyx.utils.add
class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
private val model: LabelModel by viewModels()
private lateinit var selectedLabels: ArrayList<String>
override fun onCreate(savedInstanceState: Bundle?) {
@ -26,12 +28,12 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
binding = ActivityLabelBinding.inflate(layoutInflater)
setContentView(binding.root)
val savedList = savedInstanceState?.getStringArrayList(EXTRA_SELECTED_LABELS)
val passedList = requireNotNull(intent.getStringArrayListExtra(EXTRA_SELECTED_LABELS))
val savedList = savedInstanceState?.getStringArrayList(SELECTED_LABELS)
val passedList = requireNotNull(intent.getStringArrayListExtra(SELECTED_LABELS))
selectedLabels = savedList ?: passedList
val result = Intent()
result.putExtra(EXTRA_SELECTED_LABELS, selectedLabels)
result.putExtra(SELECTED_LABELS, selectedLabels)
setResult(RESULT_OK, result)
setupToolbar()
@ -40,7 +42,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(EXTRA_SELECTED_LABELS, selectedLabels)
outState.putStringArrayList(SELECTED_LABELS, selectedLabels)
}
private fun setupToolbar() {
@ -56,19 +58,21 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.add_label)
.setView(binding.root)
.setCancelButton()
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.save) { dialog, _ ->
val value = binding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
val label = Label(value)
baseModel.insertLabel(label) { success ->
model.insertLabel(label) { success ->
if (success) {
dialog.dismiss()
} else showToast(R.string.label_exists)
} else Toast.makeText(this, R.string.label_exists, Toast.LENGTH_LONG).show()
}
}
}
.showAndFocus(binding.EditText, allowFullSize = true)
.show()
binding.EditText.requestFocus()
}
private fun setupRecyclerView() {
@ -84,7 +88,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
}
}
binding.MainListView.apply {
binding.RecyclerView.apply {
setHasFixedSize(true)
adapter = labelAdapter
addItemDecoration(
@ -92,7 +96,7 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
)
}
baseModel.labels.observe(this) { labels ->
model.labels.observe(this) { labels ->
labelAdapter.submitList(labels)
if (labels.isEmpty()) {
binding.EmptyState.visibility = View.VISIBLE
@ -101,6 +105,6 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
}
companion object {
const val EXTRA_SELECTED_LABELS = "notallyx.intent.extra.SELECTED_LABELS"
const val SELECTED_LABELS = "SELECTED_LABELS"
}
}

View file

@ -5,9 +5,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.os.BundleCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
@ -19,13 +17,10 @@ import com.philkes.notallyx.data.model.Converters
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.databinding.ActivityViewImageBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.view.Constants
import com.philkes.notallyx.presentation.view.note.image.ImageAdapter
import com.philkes.notallyx.utils.getExternalImagesDirectory
import com.philkes.notallyx.utils.getUriForFile
import com.philkes.notallyx.utils.wrapWithChooser
import com.philkes.notallyx.utils.IO
import com.philkes.notallyx.utils.add
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@ -37,47 +32,36 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
private var currentImage: FileAttachment? = null
private lateinit var deletedImages: ArrayList<FileAttachment>
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityViewImageBinding.inflate(layoutInflater)
setContentView(binding.root)
val savedList =
savedInstanceState?.let {
BundleCompat.getParcelableArrayList(
it,
EXTRA_DELETED_IMAGES,
FileAttachment::class.java,
)
}
val savedList = savedInstanceState?.getParcelableArrayList<FileAttachment>(DELETED_IMAGES)
deletedImages = savedList ?: ArrayList()
val resultIntent = Intent()
resultIntent.putExtra(EXTRA_DELETED_IMAGES, deletedImages)
setResult(RESULT_OK, resultIntent)
val result = Intent()
result.putExtra(DELETED_IMAGES, deletedImages)
setResult(RESULT_OK, result)
val savedImage =
savedInstanceState?.let {
BundleCompat.getParcelable(it, CURRENT_IMAGE, FileAttachment::class.java)
}
val savedImage = savedInstanceState?.getParcelable<FileAttachment>(CURRENT_IMAGE)
if (savedImage != null) {
currentImage = savedImage
}
binding.MainListView.apply {
binding.RecyclerView.apply {
setHasFixedSize(true)
layoutManager =
LinearLayoutManager(this@ViewImageActivity, RecyclerView.HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(binding.MainListView)
PagerSnapHelper().attachToRecyclerView(binding.RecyclerView)
}
val initial = intent.getIntExtra(EXTRA_POSITION, 0)
binding.MainListView.scrollToPosition(initial)
val initial = intent.getIntExtra(POSITION, 0)
binding.RecyclerView.scrollToPosition(initial)
val database = NotallyDatabase.getDatabase(application)
val id = intent.getLongExtra(EXTRA_SELECTED_BASE_NOTE, 0)
val id = intent.getLongExtra(Constants.SelectedBaseNote, 0)
database.observe(this@ViewImageActivity) {
lifecycleScope.launch {
@ -86,33 +70,33 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
val images = ArrayList<FileAttachment>(original.size)
original.filterNotTo(images) { image -> deletedImages.contains(image) }
val mediaRoot = application.getExternalImagesDirectory()
val mediaRoot = IO.getExternalImagesDirectory(application)
val adapter = ImageAdapter(mediaRoot, images)
binding.MainListView.adapter = adapter
binding.RecyclerView.adapter = adapter
setupToolbar(binding, adapter)
}
}
exportFileActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri -> writeImageToUri(uri) }
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.apply {
putParcelable(CURRENT_IMAGE, currentImage)
putParcelableArrayList(EXTRA_DELETED_IMAGES, deletedImages)
putParcelableArrayList(DELETED_IMAGES, deletedImages)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_EXPORT_FILE && resultCode == RESULT_OK) {
data?.data?.let { uri -> writeImageToUri(uri) }
}
}
private fun setupToolbar(binding: ActivityViewImageBinding, adapter: ImageAdapter) {
binding.Toolbar.setNavigationOnClickListener { finish() }
val layoutManager = binding.MainListView.layoutManager as LinearLayoutManager
val layoutManager = binding.RecyclerView.layoutManager as LinearLayoutManager
adapter.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() {
@ -123,7 +107,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
}
)
binding.MainListView.addOnScrollListener(
binding.RecyclerView.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@ -157,48 +141,46 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
}
private fun share(image: FileAttachment) {
val mediaRoot = application.getExternalImagesDirectory()
val mediaRoot = IO.getExternalImagesDirectory(application)
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
if (file != null && file.exists()) {
val uri = getUriForFile(file)
val intent =
Intent(Intent.ACTION_SEND)
.apply {
type = image.mimeType
putExtra(Intent.EXTRA_STREAM, uri)
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
// Necessary for sharesheet to show a preview of the image
// Check ->
// https://commonsware.com/blog/2021/01/07/action_send-share-sheet-clipdata.html
clipData = ClipData.newRawUri(null, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
.wrapWithChooser(this@ViewImageActivity)
startActivity(intent)
val intent =
Intent(Intent.ACTION_SEND).apply {
type = image.mimeType
putExtra(Intent.EXTRA_STREAM, uri)
// Necessary for sharesheet to show a preview of the image
// Check ->
// https://commonsware.com/blog/2021/01/07/action_send-share-sheet-clipdata.html
clipData = ClipData.newRawUri(null, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, null)
startActivity(chooser)
}
}
private fun saveToDevice(image: FileAttachment) {
val mediaRoot = application.getExternalImagesDirectory()
val mediaRoot = IO.getExternalImagesDirectory(application)
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
if (file != null && file.exists()) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT)
.apply {
type = image.mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX Image")
}
.wrapWithChooser(this)
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = image.mimeType
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "NotallyX Image")
}
currentImage = image
exportFileActivityResultLauncher.launch(intent)
startActivityForResult(intent, REQUEST_EXPORT_FILE)
}
}
private fun writeImageToUri(uri: Uri) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val mediaRoot = application.getExternalImagesDirectory()
val mediaRoot = IO.getExternalImagesDirectory(application)
val file =
if (mediaRoot != null) File(mediaRoot, requireNotNull(currentImage).localName)
else null
@ -219,7 +201,7 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
private fun delete(position: Int, adapter: ImageAdapter) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_image_forever)
.setCancelButton()
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
val image = adapter.items.removeAt(position)
deletedImages.add(image)
@ -232,8 +214,9 @@ class ViewImageActivity : LockedActivity<ActivityViewImageBinding>() {
}
companion object {
const val EXTRA_POSITION = "notallyx.intent.extra.POSITION"
const val POSITION = "POSITION"
const val CURRENT_IMAGE = "CURRENT_IMAGE"
const val EXTRA_DELETED_IMAGES = "notallyx.intent.extra.DELETED_IMAGES"
const val DELETED_IMAGES = "DELETED_IMAGES"
private const val REQUEST_EXPORT_FILE = 40
}
}

View file

@ -1,24 +0,0 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.DatePickerDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.philkes.notallyx.utils.now
import java.util.Calendar
import java.util.Date
class DatePickerFragment(
private val date: Date?,
private val listener: DatePickerDialog.OnDateSetListener,
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val now = now()
val c = date?.let { Calendar.getInstance().apply { time = it } } ?: now
val year = c.get(Calendar.YEAR)
val month = c.get(Calendar.MONTH)
val day = c.get(Calendar.DAY_OF_MONTH)
return DatePickerDialog(requireContext(), listener, year, month, day)
}
}

View file

@ -1,145 +0,0 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.utils.canScheduleAlarms
import com.philkes.notallyx.utils.cancelReminder
import com.philkes.notallyx.utils.createChannelIfNotExists
import com.philkes.notallyx.utils.getOpenNotePendingIntent
import com.philkes.notallyx.utils.scheduleReminder
import com.philkes.notallyx.utils.truncate
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* [BroadcastReceiver] for sending notifications via [NotificationManager] for [Reminder]s.
* Reschedules reminders on [Intent.ACTION_BOOT_COMPLETED] or if
* [AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED] has changed and exact alarms
* are allowed. For [Reminder] that have [Reminder.repetition] it automatically reschedules the next
* alarm.
*/
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d(TAG, "onReceive: ${intent?.action}")
if (intent == null || context == null) {
return
}
val canScheduleExactAlarms = context.canScheduleAlarms()
if (intent.action == null) {
if (!canScheduleExactAlarms) {
return
}
val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L)
val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
notify(context, noteId, reminderId)
} else {
when {
canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED ->
rescheduleAlarms(context)
intent.action ==
AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
if (canScheduleExactAlarms) {
rescheduleAlarms(context)
} else {
cancelAlarms(context)
}
}
}
}
}
private fun notify(context: Context, noteId: Long, reminderId: Long) {
Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId")
CoroutineScope(Dispatchers.IO).launch {
val database =
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
val manager = context.getSystemService<NotificationManager>()!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createChannelIfNotExists(
NOTIFICATION_CHANNEL_ID,
importance = NotificationManager.IMPORTANCE_HIGH,
)
}
database.getBaseNoteDao().get(noteId)?.let { note ->
val notification =
NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notebook)
.setContentTitle(note.title) // Set title from intent
.setContentText(note.body.truncate(200)) // Set content text from intent
.setPriority(NotificationCompat.PRIORITY_HIGH)
.addAction(
R.drawable.visibility,
context.getString(R.string.open_note),
context.getOpenNotePendingIntent(note),
)
.build()
note.reminders
.find { it.id == reminderId }
?.let { reminder: Reminder ->
manager.notify(note.id.toString(), reminderId.toInt(), notification)
context.scheduleReminder(note.id, reminder, forceRepetition = true)
}
}
}
}
private fun rescheduleAlarms(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
val database =
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
val now = Date()
val noteReminders = database.getBaseNoteDao().getAllReminders()
val noteRemindersWithFutureNotify =
noteReminders.flatMap { (noteId, reminders) ->
reminders
.filter { reminder ->
reminder.repetition != null || reminder.dateTime.after(now)
}
.map { reminder -> Pair(noteId, reminder) }
}
Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms")
noteRemindersWithFutureNotify.forEach { (noteId, reminder) ->
context.scheduleReminder(noteId, reminder)
}
}
}
private fun cancelAlarms(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
val database =
NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
val noteReminders = database.getBaseNoteDao().getAllReminders()
val noteRemindersWithFutureNotify =
noteReminders.flatMap { (noteId, reminders) ->
reminders.map { reminder -> Pair(noteId, reminder.id) }
}
Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms")
noteRemindersWithFutureNotify.forEach { (noteId, reminderId) ->
context.cancelReminder(noteId, reminderId)
}
}
}
companion object {
private const val TAG = "ReminderReceiver"
private const val NOTIFICATION_CHANNEL_ID = "Reminders"
const val EXTRA_REMINDER_ID = "notallyx.intent.extra.REMINDER_ID"
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
}
}

View file

@ -1,328 +0,0 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.text.Editable
import android.view.View
import android.widget.Button
import android.widget.RadioButton
import android.widget.TimePicker
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.Repetition
import com.philkes.notallyx.data.model.RepetitionTimeUnit
import com.philkes.notallyx.data.model.toCalendar
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.ActivityRemindersBinding
import com.philkes.notallyx.databinding.DialogReminderCustomRepetitionBinding
import com.philkes.notallyx.databinding.DialogReminderRepetitionBinding
import com.philkes.notallyx.presentation.activity.LockedActivity
import com.philkes.notallyx.presentation.add
import com.philkes.notallyx.presentation.checkAlarmPermission
import com.philkes.notallyx.presentation.checkNotificationPermission
import com.philkes.notallyx.presentation.initListView
import com.philkes.notallyx.presentation.setCancelButton
import com.philkes.notallyx.presentation.showAndFocus
import com.philkes.notallyx.presentation.view.main.reminder.ReminderAdapter
import com.philkes.notallyx.presentation.view.main.reminder.ReminderListener
import com.philkes.notallyx.presentation.viewmodel.NotallyModel
import com.philkes.notallyx.utils.canScheduleAlarms
import com.philkes.notallyx.utils.now
import java.util.Calendar
import kotlinx.coroutines.launch
class RemindersActivity : LockedActivity<ActivityRemindersBinding>(), ReminderListener {
private lateinit var alarmPermissionActivityResultLauncher: ActivityResultLauncher<Intent>
private val model: NotallyModel by viewModels()
private lateinit var reminderAdapter: ReminderAdapter
private var selectedReminder: Reminder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRemindersBinding.inflate(layoutInflater)
setContentView(binding.root)
setupToolbar()
setupRecyclerView()
alarmPermissionActivityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (canScheduleAlarms()) {
showDatePickerDialog(selectedReminder)
}
}
val noteId = intent.getLongExtra(NOTE_ID, 0L)
lifecycleScope.launch {
model.setState(noteId)
if (model.reminders.value.isEmpty()) {
showDatePickerDialog()
} else if (!canScheduleAlarms()) {
checkNotificationPermission(
REQUEST_NOTIFICATION_PERMISSION_REQUEST_CODE,
alsoCheckAlarmPermission = true,
) {}
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE -> {
if (
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
checkAlarmPermission(alarmPermissionActivityResultLauncher) {
showDatePickerDialog(selectedReminder)
}
}
}
}
}
private fun setupToolbar() {
binding.Toolbar.apply {
setNavigationOnClickListener { finish() }
menu.add(R.string.add_reminder, R.drawable.add) { showDatePickerDialog() }
}
}
private fun setupRecyclerView() {
reminderAdapter = ReminderAdapter(this)
binding.MainListView.apply {
initListView(this@RemindersActivity)
adapter = reminderAdapter
}
model.reminders.observe(this) { reminders ->
reminderAdapter.submitList(reminders)
if (reminders.isEmpty()) {
binding.EmptyState.visibility = View.VISIBLE
} else binding.EmptyState.visibility = View.INVISIBLE
}
}
private fun showDatePickerDialog(reminder: Reminder? = null, calendar: Calendar? = null) {
selectedReminder = reminder
checkNotificationPermission(
REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE,
alsoCheckAlarmPermission = true,
alarmPermissionResultLauncher = alarmPermissionActivityResultLauncher,
) {
DatePickerFragment(calendar?.time ?: reminder?.dateTime) { _, year, month, day ->
val usedCalendar = calendar ?: reminder?.dateTime?.toCalendar() ?: now()
usedCalendar.set(year, month, day)
showTimePickerDialog(reminder, usedCalendar)
}
.show(supportFragmentManager, "reminderDatePicker")
}
}
private fun showTimePickerDialog(reminder: Reminder? = null, calendar: Calendar) {
TimePickerFragment(
calendar,
object : TimePickerListener {
override fun onBack() {
showDatePickerDialog(reminder, calendar)
}
override fun onTimeSet(view: TimePicker?, hourOfDay: Int, minute: Int) {
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay)
calendar.set(Calendar.MINUTE, minute)
showRepetitionDialog(reminder, calendar) { updatedRepetition ->
val updatedReminder =
Reminder(
reminder?.id ?: NEW_REMINDER_ID,
calendar.time,
updatedRepetition,
)
if (reminder != null) {
lifecycleScope.launch { model.updateReminder(updatedReminder) }
} else {
lifecycleScope.launch { model.addReminder(updatedReminder) }
}
}
}
},
)
.show(supportFragmentManager, "reminderTimePicker")
}
private fun showRepetitionDialog(
reminder: Reminder? = null,
calendar: Calendar,
fromCustomRepetitionDialog: Boolean = false,
onRepetitionSelected: (Repetition?) -> Unit,
) {
val dialogView =
DialogReminderRepetitionBinding.inflate(layoutInflater).apply {
if (reminder == null && fromCustomRepetitionDialog) {
None.isChecked = true
} else {
reminder?.repetition.apply {
when {
this == null -> None.isChecked = true
value == 1 && unit == RepetitionTimeUnit.DAYS -> Daily.isChecked = true
value == 1 && unit == RepetitionTimeUnit.WEEKS ->
Weekly.isChecked = true
value == 1 && unit == RepetitionTimeUnit.MONTHS ->
Monthly.isChecked = true
value == 1 && unit == RepetitionTimeUnit.YEARS ->
Yearly.isChecked = true
fromCustomRepetitionDialog -> Custom.isChecked = true
else -> {
showCustomRepetitionDialog(reminder, calendar, onRepetitionSelected)
return
}
}
}
}
}
val dialog =
MaterialAlertDialogBuilder(this)
.setTitle(R.string.repetition)
.setView(dialogView.root)
.setPositiveButton(R.string.save) { _, _ ->
val repetition =
when (dialogView.RepetitionOptions.checkedRadioButtonId) {
R.id.None -> null
R.id.Daily -> Repetition(1, RepetitionTimeUnit.DAYS)
R.id.Weekly -> Repetition(1, RepetitionTimeUnit.WEEKS)
R.id.Monthly -> Repetition(1, RepetitionTimeUnit.MONTHS)
R.id.Yearly -> Repetition(1, RepetitionTimeUnit.YEARS)
R.id.Custom -> reminder?.repetition?.copy()
else -> null
}
onRepetitionSelected(repetition)
}
.setNegativeButton(R.string.back) { _, _ ->
showTimePickerDialog(reminder, calendar)
}
.show()
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
dialogView.apply {
Custom.setOnClickListener {
dialog.dismiss()
showCustomRepetitionDialog(reminder, calendar, onRepetitionSelected)
}
None.setOnCheckedEnableButton(positiveButton)
Daily.setOnCheckedEnableButton(positiveButton)
Weekly.setOnCheckedEnableButton(positiveButton)
Monthly.setOnCheckedEnableButton(positiveButton)
Yearly.setOnCheckedEnableButton(positiveButton)
}
}
private fun showCustomRepetitionDialog(
reminder: Reminder? = null,
calendar: Calendar,
onRepetitionSelected: (Repetition?) -> Unit,
) {
val dialogView =
DialogReminderCustomRepetitionBinding.inflate(layoutInflater).apply {
reminder?.repetition?.let {
when (it.unit) {
RepetitionTimeUnit.MINUTES -> Minutes
RepetitionTimeUnit.HOURS -> Hours
RepetitionTimeUnit.DAYS -> Days
RepetitionTimeUnit.WEEKS -> Weeks
RepetitionTimeUnit.MONTHS -> Months
RepetitionTimeUnit.YEARS -> Years
}.isChecked = true
Value.setText(it.value.toString())
}
}
val dialog =
MaterialAlertDialogBuilder(this)
.setTitle(R.string.repetition_custom)
.setView(dialogView.root)
.setPositiveButton(R.string.save) { _, _ ->
val value = dialogView.Value.text.toString().toIntOrNull() ?: 1
val selectedTimeUnit =
when (dialogView.TimeUnitGroup.checkedRadioButtonId) {
R.id.Minutes -> RepetitionTimeUnit.MINUTES
R.id.Hours -> RepetitionTimeUnit.HOURS
R.id.Days -> RepetitionTimeUnit.DAYS
R.id.Weeks -> RepetitionTimeUnit.WEEKS
R.id.Months -> RepetitionTimeUnit.MONTHS
R.id.Years -> RepetitionTimeUnit.YEARS
else -> null
}
onRepetitionSelected(selectedTimeUnit?.let { Repetition(value, it) })
}
.setBackgroundInsetBottom(0)
.setBackgroundInsetTop(0)
.setNegativeButton(R.string.back) { dialog, _ ->
dialog.dismiss()
showRepetitionDialog(
reminder,
calendar,
fromCustomRepetitionDialog = true,
onRepetitionSelected,
)
}
.showAndFocus(dialogView.Value, allowFullSize = true)
val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
dialogView.Value.doAfterTextChanged { text ->
positiveButton.isEnabled = text.hasValueBiggerZero()
}
positiveButton.isEnabled = reminder?.repetition != null
}
private fun RadioButton.setOnCheckedEnableButton(button: Button) {
setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
button.isEnabled = true
}
}
}
private fun Editable?.hasValueBiggerZero() =
(!isNullOrEmpty() && toString().toIntOrNull()?.let { it > 0 } ?: false)
private fun confirmDeletion(reminder: Reminder) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_reminder)
.setMessage(
"${reminder.dateTime.toText()}\n${reminder.repetition?.toText(this) ?: getString(R.string.reminder_no_repetition)}"
)
.setPositiveButton(R.string.delete) { _, _ ->
lifecycleScope.launch { model.removeReminder(reminder) }
}
.setCancelButton()
.show()
}
override fun delete(reminder: Reminder) {
confirmDeletion(reminder)
}
override fun edit(reminder: Reminder) {
showDatePickerDialog(reminder)
}
companion object {
const val NOTE_ID = "NOTE_ID"
const val REQUEST_NOTIFICATION_PERMISSION_ON_OPEN_REQUEST_CODE = 101
const val REQUEST_NOTIFICATION_PERMISSION_REQUEST_CODE = 102
const val NEW_REMINDER_ID = -1L
}
}

View file

@ -1,32 +0,0 @@
package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.Dialog
import android.app.TimePickerDialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.format.DateFormat
import androidx.fragment.app.DialogFragment
import com.philkes.notallyx.R
import java.util.Calendar
class TimePickerFragment(private val calendar: Calendar, private val listener: TimePickerListener) :
DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
val dialog =
TimePickerDialog(activity, listener, hour, minute, DateFormat.is24HourFormat(activity))
dialog.setButton(
DialogInterface.BUTTON_NEGATIVE,
requireContext().getText(R.string.back),
) { _, _ ->
listener.onBack()
}
return dialog
}
}
interface TimePickerListener : TimePickerDialog.OnTimeSetListener {
fun onBack()
}

View file

@ -0,0 +1,6 @@
package com.philkes.notallyx.presentation.view
object Constants {
const val SelectedLabel = "SelectedLabel"
const val SelectedBaseNote = "SelectedBaseNote"
}

View file

@ -10,26 +10,28 @@ import com.philkes.notallyx.data.model.Header
import com.philkes.notallyx.data.model.Item
import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding
import com.philkes.notallyx.databinding.RecyclerHeaderBinding
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteColorSort
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteCreationDateSort
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteModifiedDateSort
import com.philkes.notallyx.presentation.view.main.sorting.BaseNoteTitleSort
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSort
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByModifiedDate
import com.philkes.notallyx.presentation.view.misc.NotesSorting.autoSortByTitle
import com.philkes.notallyx.presentation.view.misc.SortDirection
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import java.io.File
class BaseNoteAdapter(
private val selectedIds: Set<Long>,
private val dateFormat: DateFormat,
private var notesSort: NotesSort,
private val preferences: BaseNoteVHPreferences,
private val dateFormat: String,
private val textSize: String,
private val maxItems: Int,
private val maxLines: Int,
private val maxTitle: Int,
private val imageRoot: File?,
private val listener: ItemListener,
private val listener: ListItemListener,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var list = SortedList(Item::class.java, notesSort.createCallback())
private var list =
SortedList(Item::class.java, BaseNoteCreationDateSort(this, SortDirection.ASC))
override fun getItemViewType(position: Int): Int {
return when (list[position]) {
@ -46,12 +48,7 @@ class BaseNoteAdapter(
when (val item = list[position]) {
is Header -> (holder as HeaderVH).bind(item)
is BaseNote ->
(holder as BaseNoteVH).bind(
item,
imageRoot,
selectedIds.contains(item.id),
notesSort.sortedBy,
)
(holder as BaseNoteVH).bind(item, imageRoot, selectedIds.contains(item.id))
}
}
@ -74,14 +71,19 @@ class BaseNoteAdapter(
}
else -> {
val binding = RecyclerBaseNoteBinding.inflate(inflater, parent, false)
BaseNoteVH(binding, dateFormat, preferences, listener)
BaseNoteVH(binding, dateFormat, textSize, maxItems, maxLines, maxTitle, listener)
}
}
}
fun setNotesSort(notesSort: NotesSort) {
this.notesSort = notesSort
replaceSortCallback(notesSort.createCallback())
fun setSorting(sortBy: String, sortDirection: SortDirection) {
val sortCallback =
when (sortBy) {
autoSortByTitle -> BaseNoteTitleSort(this, sortDirection)
autoSortByModifiedDate -> BaseNoteModifiedDateSort(this, sortDirection)
else -> BaseNoteCreationDateSort(this, sortDirection)
}
replaceSorting(sortCallback)
}
fun getItem(position: Int): Item? {
@ -95,17 +97,7 @@ class BaseNoteAdapter(
list.replaceAll(items)
}
private fun NotesSort.createCallback() =
when (sortedBy) {
NotesSortBy.TITLE -> BaseNoteTitleSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.MODIFIED_DATE ->
BaseNoteModifiedDateSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.CREATION_DATE ->
BaseNoteCreationDateSort(this@BaseNoteAdapter, sortDirection)
NotesSortBy.COLOR -> BaseNoteColorSort(this@BaseNoteAdapter, sortDirection)
}
private fun replaceSortCallback(sortCallback: SortedListAdapterCallback<Item>) {
private fun replaceSorting(sortCallback: SortedListAdapterCallback<Item>) {
val mutableList = mutableListOf<Item>()
for (i in 0 until list.size()) {
mutableList.add(list[i])
@ -117,7 +109,7 @@ class BaseNoteAdapter(
private fun handleCheck(holder: RecyclerView.ViewHolder, position: Int) {
val baseNote = list[position] as BaseNote
(holder as BaseNoteVH).updateCheck(selectedIds.contains(baseNote.id), baseNote.color)
(holder as BaseNoteVH).updateCheck(selectedIds.contains(baseNote.id))
}
private fun <T> SortedList<T>.toList(): List<T> {

View file

@ -2,14 +2,12 @@ package com.philkes.notallyx.presentation.view.main
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
@ -20,44 +18,34 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.data.model.FileAttachment
import com.philkes.notallyx.data.model.ListItem
import com.philkes.notallyx.data.model.SpanRepresentation
import com.philkes.notallyx.data.model.Type
import com.philkes.notallyx.data.model.hasUpcomingNotification
import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding
import com.philkes.notallyx.presentation.applySpans
import com.philkes.notallyx.presentation.bindLabels
import com.philkes.notallyx.presentation.displayFormattedTimestamp
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getQuantityString
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
import com.philkes.notallyx.presentation.viewmodel.preference.TextSize
import com.philkes.notallyx.presentation.view.misc.TextSize
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import com.philkes.notallyx.utils.Operations
import com.philkes.notallyx.utils.applySpans
import com.philkes.notallyx.utils.displayFormattedTimestamp
import com.philkes.notallyx.utils.dp
import com.philkes.notallyx.utils.getQuantityString
import java.io.File
data class BaseNoteVHPreferences(
val textSize: TextSize,
val maxItems: Int,
val maxLines: Int,
val maxTitleLines: Int,
val hideLabels: Boolean,
val hideImages: Boolean,
)
class BaseNoteVH(
private val binding: RecyclerBaseNoteBinding,
private val dateFormat: DateFormat,
private val preferences: BaseNoteVHPreferences,
listener: ItemListener,
private val dateFormat: String,
private val textSize: String,
private val maxItems: Int,
maxLines: Int,
maxTitle: Int,
listener: ListItemListener,
) : RecyclerView.ViewHolder(binding.root) {
init {
val title = preferences.textSize.displayTitleSize
val body = preferences.textSize.displayBodySize
val title = TextSize.getDisplayTitleSize(textSize)
val body = TextSize.getDisplayBodySize(textSize)
binding.apply {
Title.setTextSize(TypedValue.COMPLEX_UNIT_SP, title)
@ -69,107 +57,67 @@ class BaseNoteVH(
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
}
Title.maxLines = preferences.maxTitleLines
Note.maxLines = preferences.maxLines
Title.maxLines = maxTitle
Note.maxLines = maxLines
root.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
root.setOnClickListener { listener.onClick(adapterPosition) }
root.setOnLongClickListener {
listener.onLongClick(absoluteAdapterPosition)
listener.onLongClick(adapterPosition)
return@setOnLongClickListener true
}
}
}
fun updateCheck(checked: Boolean, color: String) {
if (checked) {
binding.root.strokeWidth = 3.dp
} else {
binding.root.strokeWidth = if (color == BaseNote.COLOR_DEFAULT) 1.dp else 0
}
fun updateCheck(checked: Boolean) {
binding.root.isChecked = checked
}
fun bind(baseNote: BaseNote, imageRoot: File?, checked: Boolean, sortBy: NotesSortBy) {
updateCheck(checked, baseNote.color)
fun bind(baseNote: BaseNote, imageRoot: File?, checked: Boolean) {
updateCheck(checked)
when (baseNote.type) {
Type.NOTE -> bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty())
Type.LIST -> bindList(baseNote.items, baseNote.title.isEmpty())
Type.NOTE -> bindNote(baseNote.body, baseNote.spans)
Type.LIST -> bindList(baseNote.items)
}
val (date, datePrefixResId) =
when (sortBy) {
NotesSortBy.CREATION_DATE -> Pair(baseNote.timestamp, R.string.creation_date)
NotesSortBy.MODIFIED_DATE ->
Pair(baseNote.modifiedTimestamp, R.string.modified_date)
else -> Pair(null, null)
}
binding.Date.displayFormattedTimestamp(date, dateFormat, datePrefixResId)
binding.Date.displayFormattedTimestamp(baseNote.timestamp, dateFormat)
setColor(baseNote.color)
setImages(baseNote.images, imageRoot)
setFiles(baseNote.files)
binding.Title.apply {
text = baseNote.title
isVisible = baseNote.title.isNotEmpty()
updatePadding(
bottom =
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 else 8.dp
)
setCompoundDrawablesWithIntrinsicBounds(
if (baseNote.type == Type.LIST && preferences.maxItems < 1)
R.drawable.checkbox_small
else 0,
0,
0,
0,
)
}
if (preferences.hideLabels) {
binding.LabelGroup.visibility = GONE
} else {
binding.LabelGroup.bindLabels(
baseNote.labels,
preferences.textSize,
binding.Note.isVisible || binding.Title.isVisible,
)
}
Operations.bindLabels(binding.LabelGroup, baseNote.labels, textSize)
if (baseNote.isEmpty()) {
if (isEmpty(baseNote)) {
binding.Title.apply {
setText(baseNote.getEmptyMessage())
isVisible = true
setText(getEmptyMessage(baseNote))
visibility = View.VISIBLE
}
}
setColor(baseNote.color)
binding.RemindersView.isVisible = baseNote.reminders.any { it.hasUpcomingNotification() }
}
private fun bindNote(body: String, spans: List<SpanRepresentation>, isTitleEmpty: Boolean) {
binding.LinearLayout.visibility = GONE
private fun bindNote(body: String, spans: List<SpanRepresentation>) {
binding.LinearLayout.visibility = View.GONE
binding.Note.apply {
text = body.applySpans(spans)
if (preferences.maxLines < 1) {
isVisible = isTitleEmpty
maxLines = if (isTitleEmpty) 1 else preferences.maxLines
} else {
isVisible = body.isNotEmpty()
}
isVisible = body.isNotEmpty()
}
}
private fun bindList(items: List<ListItem>, isTitleEmpty: Boolean) {
private fun bindList(items: List<ListItem>) {
binding.apply {
Note.visibility = GONE
Note.visibility = View.GONE
if (items.isEmpty()) {
LinearLayout.visibility = GONE
LinearLayout.visibility = View.GONE
} else {
LinearLayout.visibility = VISIBLE
val forceShowFirstItem = preferences.maxItems < 1 && isTitleEmpty
val filteredList = items.take(if (forceShowFirstItem) 1 else preferences.maxItems)
LinearLayout.visibility = View.VISIBLE
val filteredList = items.take(maxItems)
LinearLayout.children.forEachIndexed { index, view ->
if (view.id != R.id.ItemsRemaining) {
if (index < filteredList.size) {
@ -177,43 +125,47 @@ class BaseNoteVH(
(view as TextView).apply {
text = item.body
handleChecked(this, item.checked)
visibility = VISIBLE
visibility = View.VISIBLE
if (item.isChild) {
updateLayoutParams<LinearLayout.LayoutParams> {
marginStart = 20.dp
}
}
if (index == filteredList.lastIndex) {
updatePadding(bottom = 0)
val layoutParams = layoutParams as LinearLayout.LayoutParams
layoutParams.marginStart = 150.dp
setLayoutParams(layoutParams)
}
}
} else view.visibility = GONE
} else view.visibility = View.GONE
}
}
if (preferences.maxItems > 0 && items.size > preferences.maxItems) {
if (items.size > maxItems) {
ItemsRemaining.apply {
visibility = VISIBLE
text = (items.size - preferences.maxItems).toString()
visibility = View.VISIBLE
text = (items.size - maxItems).toString()
}
} else ItemsRemaining.visibility = GONE
} else ItemsRemaining.visibility = View.GONE
}
}
}
private fun setColor(color: String) {
private fun setColor(color: Color) {
binding.root.apply {
val colorInt = context.extractColor(color)
setCardBackgroundColor(colorInt)
setControlsContrastColorForAllViews(colorInt)
if (color == Color.DEFAULT) {
val stroke = ContextCompat.getColorStateList(context, R.color.chip_stroke)
setStrokeColor(stroke)
setCardBackgroundColor(0)
} else {
strokeColor = 0
val colorInt = Operations.extractColor(color, context)
setCardBackgroundColor(colorInt)
}
}
}
private fun setImages(images: List<FileAttachment>, mediaRoot: File?) {
binding.apply {
if (images.isNotEmpty() && !preferences.hideImages) {
ImageView.visibility = VISIBLE
Message.visibility = GONE
if (images.isNotEmpty()) {
ImageView.visibility = View.VISIBLE
Message.visibility = View.GONE
val image = images[0]
val file = if (mediaRoot != null) File(mediaRoot, image.localName) else null
@ -232,7 +184,7 @@ class BaseNoteVH(
target: Target<Drawable>?,
isFirstResource: Boolean,
): Boolean {
Message.visibility = VISIBLE
Message.visibility = View.VISIBLE
return false
}
@ -251,15 +203,15 @@ class BaseNoteVH(
if (images.size > 1) {
ImageViewMore.apply {
text = images.size.toString()
visibility = VISIBLE
visibility = View.VISIBLE
}
} else {
ImageViewMore.visibility = GONE
ImageViewMore.visibility = View.GONE
}
} else {
ImageView.visibility = GONE
Message.visibility = GONE
ImageViewMore.visibility = GONE
ImageView.visibility = View.GONE
Message.visibility = View.GONE
ImageViewMore.visibility = View.GONE
Glide.with(ImageView).clear(ImageView)
}
}
@ -268,37 +220,38 @@ class BaseNoteVH(
private fun setFiles(files: List<FileAttachment>) {
binding.apply {
if (files.isNotEmpty()) {
FileViewLayout.visibility = VISIBLE
FileView.visibility = View.VISIBLE
FileView.text = files[0].originalName
if (files.size > 1) {
FileViewMore.apply {
text = getQuantityString(R.plurals.more_files, files.size - 1)
visibility = VISIBLE
visibility = View.VISIBLE
}
} else {
FileViewMore.visibility = GONE
FileViewMore.visibility = View.GONE
}
} else {
FileViewLayout.visibility = GONE
FileView.visibility = View.GONE
FileViewMore.visibility = View.GONE
}
}
}
private fun shouldOnlyDisplayTitle(baseNote: BaseNote) =
when (baseNote.type) {
Type.NOTE -> preferences.maxLines < 1
Type.LIST -> preferences.maxItems < 1
private fun isEmpty(baseNote: BaseNote): Boolean {
return with(baseNote) {
when (type) {
Type.NOTE -> title.isBlank() && body.isBlank() && images.isEmpty()
Type.LIST -> title.isBlank() && items.isEmpty() && images.isEmpty()
}
}
}
private fun BaseNote.isEmpty() = title.isBlank() && hasNoContents() && images.isEmpty()
private fun BaseNote.hasNoContents() = body.isEmpty() && items.isEmpty()
private fun BaseNote.getEmptyMessage() =
when (type) {
private fun getEmptyMessage(baseNote: BaseNote): Int {
return when (baseNote.type) {
Type.NOTE -> R.string.empty_note
Type.LIST -> R.string.empty_list
}
}
private fun handleChecked(textView: TextView, checked: Boolean) {
if (checked) {

View file

@ -3,20 +3,19 @@ package com.philkes.notallyx.presentation.view.main
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.databinding.RecyclerColorBinding
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
class ColorAdapter(
private val colors: List<String>,
private val selectedColor: String?,
private val listener: ItemListener,
) : RecyclerView.Adapter<ColorVH>() {
class ColorAdapter(private val listener: ListItemListener) : RecyclerView.Adapter<ColorVH>() {
private val colors = Color.values()
override fun getItemCount() = colors.size
override fun onBindViewHolder(holder: ColorVH, position: Int) {
val color = colors[position]
holder.bind(color, color == selectedColor)
holder.bind(color)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ColorVH {

View file

@ -1,56 +1,20 @@
package com.philkes.notallyx.presentation.view.main
import android.content.res.ColorStateList
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.data.model.Color
import com.philkes.notallyx.databinding.RecyclerColorBinding
import com.philkes.notallyx.presentation.dp
import com.philkes.notallyx.presentation.extractColor
import com.philkes.notallyx.presentation.getColorFromAttr
import com.philkes.notallyx.presentation.getContrastFontColor
import com.philkes.notallyx.presentation.view.misc.ItemListener
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
import com.philkes.notallyx.utils.Operations
class ColorVH(private val binding: RecyclerColorBinding, listener: ItemListener) :
class ColorVH(private val binding: RecyclerColorBinding, listener: ListItemListener) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.CardView.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
binding.CardView.setOnLongClickListener {
listener.onLongClick(absoluteAdapterPosition)
true
}
binding.CardView.setOnClickListener { listener.onClick(adapterPosition) }
}
fun bind(color: String, isSelected: Boolean) {
val showAddIcon = color == BaseNote.COLOR_NEW
val context = binding.root.context
val value =
if (showAddIcon) context.getColorFromAttr(R.attr.colorOnSurface)
else context.extractColor(color)
val controlsColor = context.getContrastFontColor(value)
binding.apply {
CardView.apply {
setCardBackgroundColor(value)
contentDescription = color
if (isSelected) {
strokeWidth = 4.dp
strokeColor = controlsColor
} else {
strokeWidth = 1.dp
strokeColor = controlsColor
}
}
CardIcon.apply {
if (showAddIcon) {
setImageResource(R.drawable.add)
} else if (isSelected) {
setImageResource(R.drawable.checked_circle)
}
imageTintList = ColorStateList.valueOf(controlsColor)
isVisible = showAddIcon || isSelected
}
}
fun bind(color: Color) {
val value = Operations.extractColor(color, binding.root.context)
binding.CardView.setCardBackgroundColor(value)
}
}

View file

@ -0,0 +1,23 @@
package com.philkes.notallyx.presentation.view.main
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.databinding.RecyclerLabelBinding
import com.philkes.notallyx.presentation.view.misc.StringDiffCallback
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
class LabelAdapter(private val listener: ListItemListener) :
ListAdapter<String, LabelVH>(StringDiffCallback()) {
override fun onBindViewHolder(holder: LabelVH, position: Int) {
val label = getItem(position)
holder.bind(label)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LabelVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerLabelBinding.inflate(inflater, parent, false)
return LabelVH(binding, listener)
}
}

View file

@ -0,0 +1,23 @@
package com.philkes.notallyx.presentation.view.main
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.databinding.RecyclerLabelBinding
import com.philkes.notallyx.presentation.view.note.listitem.ListItemListener
class LabelVH(private val binding: RecyclerLabelBinding, listener: ListItemListener) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.root.apply {
setOnClickListener { listener.onClick(adapterPosition) }
setOnLongClickListener {
listener.onLongClick(adapterPosition)
return@setOnLongClickListener true
}
}
}
fun bind(value: String) {
binding.root.text = value
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.main.label
package com.philkes.notallyx.presentation.view.main
import android.view.LayoutInflater
import android.view.ViewGroup

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.main.label
package com.philkes.notallyx.presentation.view.main
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.databinding.RecyclerSelectableLabelBinding
@ -10,7 +10,7 @@ class SelectableLabelVH(
init {
binding.root.setOnCheckedChangeListener { _, isChecked ->
onChecked(absoluteAdapterPosition, isChecked)
onChecked(adapterPosition, isChecked)
}
}

View file

@ -1,35 +0,0 @@
package com.philkes.notallyx.presentation.view.main.label
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.databinding.RecyclerLabelBinding
class LabelAdapter(private val listener: LabelListener) :
ListAdapter<LabelData, LabelVH>(DiffCallback) {
override fun onBindViewHolder(holder: LabelVH, position: Int) {
val label = getItem(position)
holder.bind(label)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LabelVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerLabelBinding.inflate(inflater, parent, false)
return LabelVH(binding, listener)
}
}
data class LabelData(val label: String, var visibleInNavigation: Boolean)
private object DiffCallback : DiffUtil.ItemCallback<LabelData>() {
override fun areItemsTheSame(oldItem: LabelData, newItem: LabelData): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: LabelData, newItem: LabelData): Boolean {
return oldItem == newItem
}
}

View file

@ -1,12 +0,0 @@
package com.philkes.notallyx.presentation.view.main.label
interface LabelListener {
fun onClick(position: Int)
fun onEdit(position: Int)
fun onDelete(position: Int)
fun onToggleVisibility(position: Int)
}

View file

@ -1,27 +0,0 @@
package com.philkes.notallyx.presentation.view.main.label
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.databinding.RecyclerLabelBinding
class LabelVH(private val binding: RecyclerLabelBinding, listener: LabelListener) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.apply {
LabelText.setOnClickListener { listener.onClick(absoluteAdapterPosition) }
EditButton.setOnClickListener { listener.onEdit(absoluteAdapterPosition) }
DeleteButton.setOnClickListener { listener.onDelete(absoluteAdapterPosition) }
VisibilityButton.setOnClickListener {
listener.onToggleVisibility(absoluteAdapterPosition)
}
}
}
fun bind(value: LabelData) {
binding.LabelText.text = value.label
binding.VisibilityButton.setImageResource(
if (value.visibleInNavigation) R.drawable.visibility else R.drawable.visibility_off
)
}
}

View file

@ -1,40 +0,0 @@
package com.philkes.notallyx.presentation.view.main.reminder
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.data.dao.NoteReminder
import com.philkes.notallyx.databinding.RecyclerNoteReminderBinding
class NoteReminderAdapter(private val listener: NoteReminderListener) :
ListAdapter<NoteReminder, NoteReminderVH>(NoteReminderDiffCallback()) {
override fun onBindViewHolder(holder: NoteReminderVH, position: Int) {
val reminder = getItem(position)
holder.bind(reminder)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteReminderVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerNoteReminderBinding.inflate(inflater, parent, false)
return NoteReminderVH(binding, listener)
}
}
interface NoteReminderListener {
fun openReminder(reminder: NoteReminder)
fun openNote(reminder: NoteReminder)
}
class NoteReminderDiffCallback : DiffUtil.ItemCallback<NoteReminder>() {
override fun areItemsTheSame(oldItem: NoteReminder, newItem: NoteReminder): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: NoteReminder, newItem: NoteReminder): Boolean {
return oldItem == newItem
}
}

View file

@ -1,29 +0,0 @@
package com.philkes.notallyx.presentation.view.main.reminder
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.dao.NoteReminder
import com.philkes.notallyx.data.model.findNextNotificationDate
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.RecyclerNoteReminderBinding
class NoteReminderVH(
private val binding: RecyclerNoteReminderBinding,
private val listener: NoteReminderListener,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(value: NoteReminder) {
binding.apply {
Layout.setOnClickListener { listener.openReminder(value) }
val context = itemView.context
NoteTitle.text = value.title.ifEmpty { context.getText(R.string.empty_note) }
val nextNotificationDate = value.reminders.findNextNotificationDate()
NextNotification.text =
nextNotificationDate?.let {
"${context.getText(R.string.next)}: ${nextNotificationDate.toText()}"
} ?: context.getString(R.string.elapsed)
Reminders.text = value.reminders.size.toString()
OpenNote.setOnClickListener { listener.openNote(value) }
}
}
}

View file

@ -1,40 +0,0 @@
package com.philkes.notallyx.presentation.view.main.reminder
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.databinding.RecyclerReminderBinding
class ReminderAdapter(private val listener: ReminderListener) :
ListAdapter<Reminder, ReminderVH>(ReminderDiffCallback()) {
override fun onBindViewHolder(holder: ReminderVH, position: Int) {
val reminder = getItem(position)
holder.bind(reminder)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReminderVH {
val inflater = LayoutInflater.from(parent.context)
val binding = RecyclerReminderBinding.inflate(inflater, parent, false)
return ReminderVH(binding, listener)
}
}
interface ReminderListener {
fun delete(reminder: Reminder)
fun edit(reminder: Reminder)
}
class ReminderDiffCallback : DiffUtil.ItemCallback<Reminder>() {
override fun areItemsTheSame(oldItem: Reminder, newItem: Reminder): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Reminder, newItem: Reminder): Boolean {
return oldItem == newItem
}
}

View file

@ -1,24 +0,0 @@
package com.philkes.notallyx.presentation.view.main.reminder
import androidx.recyclerview.widget.RecyclerView
import com.philkes.notallyx.R
import com.philkes.notallyx.data.model.Reminder
import com.philkes.notallyx.data.model.toText
import com.philkes.notallyx.databinding.RecyclerReminderBinding
class ReminderVH(
private val binding: RecyclerReminderBinding,
private val listener: ReminderListener,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(value: Reminder) {
binding.apply {
DateTime.text = value.dateTime.toText()
Repetition.text =
value.repetition?.toText(itemView.context)
?: itemView.context.getText(R.string.reminder_no_repetition)
EditButton.setOnClickListener { listener.edit(value) }
DeleteButton.setOnClickListener { listener.delete(value) }
}
}
}

Some files were not shown because too many files have changed in this diff Show more