Compare commits

..

No commits in common. "main" and "v6.0-SNAPSHOT" have entirely different histories.

613 changed files with 14384 additions and 333453 deletions

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
ko_fi: philkes
patreon: omgodse

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,14 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

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

14
.gitignore vendored
View file

@ -1,16 +1,14 @@
*.iml
.gradle
/local.properties
/.idea/
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
*/.attach_pid*
fastlane/*
!fastlane/join-testers.png
!fastlane/metadata
Gemfile*
*.sh
!generate-changelogs.sh

View file

@ -1,25 +0,0 @@
#!/bin/sh
# Capture the list of initially staged Kotlin files
initial_staged_files=$(git diff --name-only --cached -- '*.kt')
if [ -z "$initial_staged_files" ]; then
echo "No Kotlin files staged for commit."
exit 0
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"

View file

@ -1,35 +0,0 @@
@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 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

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)*

5
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,5 @@
### Contributing
Issues are currently disabled.
Please use the pull requests tab only for translations or bug fixes.

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

103
README.md
View file

@ -1,88 +1,35 @@
<h2 align="center">
<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>
</h2>
<div style="display: flex; justify-content: space-between; width: 100%;">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" alt="Image 6" style="width: 32%;"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" alt="Image 2" style="width: 32%;"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" alt="Image 3" style="width: 32%;"/>
</div>
<div style="display: flex; justify-content: space-between; width: 100%;">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" alt="Image 4" style="width: 32%;"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" alt="Image 5" style="width: 32%;"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" alt="Image 7" style="width: 32%;"/>
</div>
### Background
Notally was created because I wanted to make something that was beautiful and at the same time, useful. It's extremely light, there are minimal dependencies and lines of code.
### Features
[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
* 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
* Add **clickable links** to notes with support for phone numbers, email addresses and web urls
* **Undo/Redo actions**
* 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
* Actions to quickly remove checked tasks
* Adaptive android app icon
* Widgets
* Auto backup
* Adjustable text size
* Support for Lollipop devices and up
* APK size of 1.4 MB (1.8 MB uncompressed)
* Color, pin and label your notes for quick organisation
* Complement your notes with pictures (JPG, PNG, WEBP)
* Export notes as TXT, JSON, HTML or PDF files with formatting
* Create rich text notes with support for bold, italics, mono space and strike-through
* Add clickable links to notes with support for phone numbers, email addresses and web urls
---
### 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)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" alt="Get it on Google Play" height="70"/>](https://play.google.com/store/apps/details?id=com.omgodse.notally)
[<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.omgodse.notally/)
### 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 email me or open a pull request.
See [Android Translations Converter](https://github.com/PhilKes/android-translations-converter-plugin) for more details
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250"/><img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250"/><img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250"/>
### Contributing
### Hall of fame
* [Top 20 Android Apps 2021!](https://www.youtube.com/watch?v=bwz13aM0qJk)
* [De-Googling Any Android Phone! (Google Apps Alternatives)](https://www.youtube.com/watch?v=RQUEgwgV99I)
* [The BEST Private Notetaking Apps Explained](https://www.youtube.com/watch?v=BJw5tKPP1PY)
* [Notally](https://www.noteapps.ca/notally/)
* [The 9 Best Simple Note-Taking Apps for Android](https://www.makeuseof.com/simple-note-apps-android/)
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.
### Copycats
Clones of Notally keep popping up on the Play Store. They are not licensed under GPL3 and usually change a few colors, include ads, etc. Please [report them](https://support.google.com/googleplay/android-developer/contact/takedown) and [inform me](mailto:omgodseapps@gmail.com) if you find a new one.
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).
### 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).
* https://play.google.com/store/apps/details?id=com.sladjan.notes
* https://play.google.com/store/apps/details?id=com.sladjan.notespro

1
app/.gitignore vendored
View file

@ -1,2 +1 @@
/build
/release

89
app/build.gradle Normal file
View file

@ -0,0 +1,89 @@
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.omgodse.notally'
defaultConfig {
applicationId 'com.omgodse.notally'
minSdk 21
targetSdk 33
versionCode 55
versionName "6.0-SNAPSHOT"
resConfigs '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"
}
}
kotlinOptions { jvmTarget = "1.8" }
buildFeatures { viewBinding true }
packagingOptions.resources {
excludes += ["DebugProbesKt.bin", "META-INF/**.version", "kotlin/**.kotlin_builtins", "kotlin-tooling-metadata.json"]
}
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
kotlinOptions.jvmTarget = "1.8"
}
ktfmt {
kotlinLangStyle()
}
tasks.withType(Test).configureEach {
// dependsOn 'ktfmtFormat'
}
dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'junit:junit:4.12'
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 "androidx.work:work-runtime:2.9.0"
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "org.ocpsoft.prettytime:prettytime:4.0.6.Final"
implementation "com.google.android.material:material:1.4.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"
}

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,13 @@
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
-keepattributes LineNumberTable,SourceFile
# 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
-dontobfuscate
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
-printmapping obfuscation/mapping.txt
-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 ** implements org.ocpsoft.prettytime.TimeUnit

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "01fd1bdfcfa83b65d83ff1209b964329",
"identityHash": "1cbb12db54fc964fac579051101a6c67",
"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, `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)",
"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, `labels` TEXT NOT NULL, `body` TEXT NOT NULL, `spans` TEXT NOT NULL, `items` TEXT NOT NULL, `images` TEXT NOT NULL, `audios` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
@ -80,12 +80,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "audios",
"columnName": "audios",
@ -140,7 +134,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, '01fd1bdfcfa83b65d83ff1209b964329')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1cbb12db54fc964fac579051101a6c67')"
]
}
}

View file

@ -1,152 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"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)",
"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
}
],
"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, 'a0ebadcc625f8b49bf549975d7288f10')"
]
}
}

View file

@ -1,158 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"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": 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,27 +6,21 @@
<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
android:name=".NotallyXApplication"
android:name=".NotallyApplication"
android:allowBackup="true"
android:appCategory="productivity"
android:dataExtractionRules="@xml/data_rules"
android:fullBackupContent="@xml/backup_content"
android:icon="@mipmap/notallyx"
android:icon="@mipmap/notally"
android:label="@string/app_name"
android:localeConfig="@xml/locales"
android:roundIcon="@mipmap/notallyx_round"
android:roundIcon="@mipmap/notally_round"
android:theme="@style/AppTheme">
<provider
@ -42,8 +36,9 @@
</provider>
<activity
android:name=".presentation.activity.main.MainActivity"
android:exported="true">
android:name=".activities.MainActivity"
android:exported="true"
android:theme="@style/MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -57,57 +52,34 @@
</activity>
<activity
android:name=".presentation.activity.note.EditListActivity"
android:name=".activities.MakeList"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".presentation.activity.note.EditNoteActivity"
android:name=".activities.TakeNote"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<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=".activities.ViewImage" />
<activity android:name=".presentation.activity.note.SelectLabelsActivity" />
<activity android:name=".presentation.activity.note.reminders.RemindersActivity" />
<activity android:name=".activities.SelectLabels" />
<activity
android:name=".presentation.activity.note.RecordAudioActivity"
android:name=".activities.RecordAudio"
android:launchMode="singleTask" />
<activity android:name=".presentation.activity.note.PlayAudioActivity" />
<activity android:name=".activities.PlayAudio" />
<activity
android:name=".presentation.activity.ConfigureWidgetActivity"
android:name=".activities.ConfigureWidget"
android:exported="false">
<intent-filter>
@ -116,23 +88,8 @@
</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:name=".widget.WidgetProvider"
android:exported="false"
android:label="@string/single_note_or_list">
@ -146,27 +103,23 @@
</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:name=".widget.WidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".utils.audio.AudioRecordService"
android:name=".AttachmentDeleteService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".audio.AudioRecordService"
android:exported="false"
android:foregroundServiceType="microphone" />
<service
android:name=".utils.audio.AudioPlayService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
android:name=".audio.AudioPlayService"
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,18 +1,17 @@
package com.philkes.notallyx.utils
package com.omgodse.notally
import androidx.lifecycle.MutableLiveData
import com.philkes.notallyx.data.model.BaseNote
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
import com.omgodse.notally.image.Event
import com.omgodse.notally.preferences.BetterLiveData
import com.omgodse.notally.room.BaseNote
class ActionMode {
val enabled = NotNullLiveData(false)
val loading = NotNullLiveData(false)
val count = NotNullLiveData(0)
val enabled = BetterLiveData(false)
val count = BetterLiveData(0)
val selectedNotes = HashMap<Long, BaseNote>()
val selectedIds = selectedNotes.keys
val closeListener = MutableLiveData<Event<Set<Long>>>()
var addListener: (() -> Unit)? = null
private fun refresh() {
count.value = selectedNotes.size
@ -24,12 +23,6 @@ class ActionMode {
refresh()
}
fun add(baseNotes: Collection<BaseNote>) {
baseNotes.forEach { selectedNotes[it.id] = it }
refresh()
addListener?.invoke()
}
fun remove(id: Long) {
selectedNotes.remove(id)
refresh()
@ -44,17 +37,8 @@ class ActionMode {
}
}
fun updateSelected(availableItemIds: List<Long>?) {
selectedNotes.keys
.filter { availableItemIds?.contains(it) == false }
.forEach { selectedNotes.remove(it) }
refresh()
}
fun isEnabled() = enabled.value
// We assume selectedNotes.size is 1
fun getFirstNote() = selectedNotes.values.first()
fun isEmpty() = selectedNotes.values.isEmpty()
}

View file

@ -0,0 +1,119 @@
package com.omgodse.notally
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.omgodse.notally.miscellaneous.IO
import com.omgodse.notally.room.Attachment
import com.omgodse.notally.room.Audio
import com.omgodse.notally.room.Image
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)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = "com.omgodse.fileUpdates"
val channel =
NotificationChannel(
channelId,
"Backups and Images",
NotificationManager.IMPORTANCE_DEFAULT,
)
manager.createNotificationChannel(channel)
builder.setChannelId(channelId)
}
builder.setContentTitle(getString(R.string.deleting_images))
builder.setSmallIcon(R.drawable.notification_delete)
builder.setProgress(0, 0, true)
builder.setOnlyAlertOnce(true)
/*
Prevent user from dismissing notification in Android 13 (33) and above
https://developer.android.com/guide/components/foreground-services#user-dismiss-notification
*/
builder.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)
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 Image ->
if (imageRoot != null) File(imageRoot, attachment.name)
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.omgodse.notally.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

@ -0,0 +1,79 @@
package com.omgodse.notally
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.omgodse.notally.miscellaneous.Export
import com.omgodse.notally.miscellaneous.IO
import com.omgodse.notally.miscellaneous.Operations
import com.omgodse.notally.preferences.AutoBackup
import com.omgodse.notally.preferences.Preferences
import com.omgodse.notally.room.Converters
import com.omgodse.notally.room.NotallyDatabase
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipOutputStream
class AutoBackupWorker(private val context: Context, params: WorkerParameters) :
Worker(context, params) {
override fun doWork(): Result {
val app = context.applicationContext as Application
val preferences = Preferences.getInstance(app)
val backupPath = preferences.autoBackup.value
if (backupPath != AutoBackup.emptyPath) {
val uri = Uri.parse(backupPath)
val folder = requireNotNull(DocumentFile.fromTreeUri(app, uri))
if (folder.exists()) {
val formatter =
SimpleDateFormat("yyyyMMdd HHmmss '(Notally Backup)'", Locale.ENGLISH)
val name = formatter.format(System.currentTimeMillis())
val file = requireNotNull(folder.createFile("application/zip", name))
val outputStream = requireNotNull(app.contentResolver.openOutputStream(file.uri))
val zipStream = ZipOutputStream(outputStream)
val database = NotallyDatabase.getDatabase(app)
database.checkpoint()
Export.backupDatabase(app, zipStream)
val imageRoot = IO.getExternalImagesDirectory(app)
val audioRoot = IO.getExternalAudioDirectory(app)
database
.getBaseNoteDao()
.getAllImages()
.asSequence()
.flatMap { string -> Converters.jsonToImages(string) }
.forEach { image ->
try {
Export.backupFile(zipStream, imageRoot, "Images", image.name)
} catch (exception: Exception) {
Operations.log(app, exception)
}
}
database
.getBaseNoteDao()
.getAllAudios()
.asSequence()
.flatMap { string -> Converters.jsonToAudios(string) }
.forEach { audio ->
try {
Export.backupFile(zipStream, audioRoot, "Audios", audio.name)
} catch (exception: Exception) {
Operations.log(app, exception)
}
}
zipStream.close()
}
}
return Result.success()
}
}

View file

@ -0,0 +1,8 @@
package com.omgodse.notally
class BackupProgress(
val inProgress: Boolean,
val current: Int,
val total: Int,
val indeterminate: Boolean,
)

View file

@ -0,0 +1,8 @@
package com.omgodse.notally
import com.omgodse.notally.room.BaseNote
object Cache {
var list: List<BaseNote> = ArrayList()
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.utils
package com.omgodse.notally
import android.graphics.RectF
import android.text.Selection

View file

@ -1,11 +1,11 @@
package com.philkes.notallyx.presentation.view.misc
package com.omgodse.notally
import android.content.Context
import android.view.ViewGroup.LayoutParams
import android.widget.LinearLayout
import androidx.core.widget.NestedScrollView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.philkes.notallyx.databinding.MenuItemBinding
import com.omgodse.notally.databinding.MenuItemBinding
class MenuDialog(context: Context) : BottomSheetDialog(context) {

View file

@ -0,0 +1,36 @@
package com.omgodse.notally
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.omgodse.notally.preferences.Preferences
import com.omgodse.notally.preferences.Theme
import java.util.concurrent.TimeUnit
class NotallyApplication : Application() {
override fun onCreate() {
super.onCreate()
val preferences = Preferences.getInstance(this)
preferences.theme.observeForever { theme ->
when (theme) {
Theme.dark ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
Theme.light ->
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
Theme.followSystem ->
AppCompatDelegate.setDefaultNightMode(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
)
}
}
val request =
PeriodicWorkRequest.Builder(AutoBackupWorker::class.java, 12, TimeUnit.HOURS).build()
WorkManager.getInstance(this)
.enqueueUniquePeriodicWork("Auto Backup", ExistingPeriodicWorkPolicy.KEEP, request)
}
}

View file

@ -0,0 +1,21 @@
package com.omgodse.notally
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatEditText
/**
* Implementation that fixes a bug in Lollipop where clicking on the overflow icon in the custom
* text selection mode causes it to end. For more information, see this ->
* https://issuetracker.google.com/issues/36937508
*/
class OverflowEditText(context: Context, attrs: AttributeSet) : AppCompatEditText(context, attrs) {
var isActionModeOn = false
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
if (!isActionModeOn) {
super.onWindowFocusChanged(hasWindowFocus)
}
}
}

View file

@ -0,0 +1,108 @@
package com.omgodse.notally.activities
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.omgodse.notally.R
import com.omgodse.notally.databinding.ActivityConfigureWidgetBinding
import com.omgodse.notally.miscellaneous.IO
import com.omgodse.notally.preferences.Preferences
import com.omgodse.notally.preferences.View
import com.omgodse.notally.recyclerview.ItemListener
import com.omgodse.notally.recyclerview.adapter.BaseNoteAdapter
import com.omgodse.notally.room.BaseNote
import com.omgodse.notally.room.Header
import com.omgodse.notally.room.NotallyDatabase
import com.omgodse.notally.viewmodels.BaseNoteModel
import com.omgodse.notally.widget.WidgetProvider
import java.util.Collections
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConfigureWidget : AppCompatActivity(), ItemListener {
private lateinit var adapter: BaseNoteAdapter
private val id by lazy {
intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val 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)
val maxItems = preferences.maxItems
val maxLines = preferences.maxLines
val maxTitle = preferences.maxTitle
val textSize = preferences.textSize.value
val dateFormat = preferences.dateFormat.value
val mediaRoot = IO.getExternalImagesDirectory(application)
adapter =
BaseNoteAdapter(
Collections.emptySet(),
dateFormat,
textSize,
maxItems,
maxLines,
maxTitle,
mediaRoot,
this,
)
binding.RecyclerView.adapter = adapter
binding.RecyclerView.setHasFixedSize(true)
binding.RecyclerView.layoutManager =
if (preferences.view.value == View.grid) {
StaggeredGridLayoutManager(2, RecyclerView.VERTICAL)
} else LinearLayoutManager(this)
val database = NotallyDatabase.getDatabase(application)
val pinned = Header(getString(R.string.pinned))
val others = Header(getString(R.string.others))
lifecycleScope.launch {
val notes =
withContext(Dispatchers.IO) {
val raw = database.getBaseNoteDao().getAllNotes()
BaseNoteModel.transform(raw, pinned, others)
}
adapter.submitList(notes)
}
}
override fun onClick(position: Int) {
if (position != -1) {
val preferences = Preferences.getInstance(application)
val noteId = (adapter.currentList[position] as BaseNote).id
preferences.updateWidget(id, noteId)
val manager = AppWidgetManager.getInstance(this)
WidgetProvider.updateWidget(this, manager, id, noteId)
val success = Intent()
success.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
setResult(RESULT_OK, success)
finish()
}
}
override fun onLongClick(position: Int) {}
}

View file

@ -0,0 +1,449 @@
package com.omgodse.notally.activities
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.print.PostPDFGenerator
import android.transition.TransitionManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.core.view.GravityCompat
import androidx.core.view.forEach
import androidx.core.widget.doAfterTextChanged
import androidx.drawerlayout.widget.DrawerLayout
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
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.platform.MaterialFade
import com.omgodse.notally.MenuDialog
import com.omgodse.notally.R
import com.omgodse.notally.databinding.ActivityMainBinding
import com.omgodse.notally.databinding.DialogColorBinding
import com.omgodse.notally.miscellaneous.Operations
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.miscellaneous.applySpans
import com.omgodse.notally.recyclerview.ItemListener
import com.omgodse.notally.recyclerview.adapter.ColorAdapter
import com.omgodse.notally.room.BaseNote
import com.omgodse.notally.room.Color
import com.omgodse.notally.room.Folder
import com.omgodse.notally.room.Type
import com.omgodse.notally.viewmodels.BaseNoteModel
import java.io.File
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var navController: NavController
private lateinit var configuration: AppBarConfiguration
private val model: BaseNoteModel by viewModels()
override fun onBackPressed() {
if (model.actionMode.enabled.value) {
model.actionMode.close(true)
} else super.onBackPressed()
}
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp(configuration)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.Toolbar)
setupFAB()
setupMenu()
setupActionMode()
setupNavigation()
setupSearch()
}
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 setupFAB() {
binding.TakeNote.setOnClickListener {
val intent = Intent(this, TakeNote::class.java)
startActivity(intent)
}
binding.MakeList.setOnClickListener {
val intent = Intent(this, MakeList::class.java)
startActivity(intent)
}
}
private fun setupMenu() {
val menu = binding.NavigationView.menu
menu.add(0, R.id.Notes, 0, R.string.notes).setCheckable(true).setIcon(R.drawable.home)
menu.add(1, R.id.Labels, 0, R.string.labels).setCheckable(true).setIcon(R.drawable.label)
menu.add(2, R.id.Deleted, 0, R.string.deleted).setCheckable(true).setIcon(R.drawable.delete)
menu
.add(2, R.id.Archived, 0, R.string.archived)
.setCheckable(true)
.setIcon(R.drawable.archive)
menu
.add(3, R.id.Settings, 0, R.string.settings)
.setCheckable(true)
.setIcon(R.drawable.settings)
}
private fun setupActionMode() {
binding.ActionMode.setNavigationOnClickListener { model.actionMode.close(true) }
val transition = MaterialFade()
transition.secondaryAnimatorProvider = null
transition.excludeTarget(binding.NavHostFragment, true)
transition.excludeChildren(binding.NavHostFragment, true)
transition.excludeTarget(binding.TakeNote, true)
transition.excludeTarget(binding.MakeList, true)
transition.excludeTarget(binding.NavigationView, true)
model.actionMode.enabled.observe(this) { enabled ->
TransitionManager.beginDelayedTransition(binding.RelativeLayout, transition)
if (enabled) {
binding.Toolbar.visibility = View.GONE
binding.ActionMode.visibility = View.VISIBLE
binding.DrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
} else {
binding.Toolbar.visibility = View.VISIBLE
binding.ActionMode.visibility = View.GONE
binding.DrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
}
}
val menu = binding.ActionMode.menu
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) { model.moveBaseNotes(Folder.DELETED) }
val archive =
menu.add(R.string.archive, R.drawable.archive) { model.moveBaseNotes(Folder.ARCHIVED) }
val restore =
menu.add(R.string.restore, R.drawable.restore) { model.moveBaseNotes(Folder.NOTES) }
val unarchive =
menu.add(R.string.unarchive, R.drawable.unarchive) { model.moveBaseNotes(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 {
val export = menu.addSubMenu(R.string.export)
export.setIcon(R.drawable.export)
export.item.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
export.add("PDF").onClick { exportToPDF() }
export.add("TXT").onClick { exportToTXT() }
export.add("JSON").onClick { exportToJSON() }
export.add("HTML").onClick { exportToHTML() }
return export.item
}
fun MenuItem.onClick(function: () -> Unit) {
setOnMenuItemClickListener {
function()
return@setOnMenuItemClickListener false
}
}
private fun share() {
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 : ItemListener {
override fun onClick(position: Int) {
dialog.dismiss()
val color = Color.entries[position]
model.colorBaseNote(color)
}
override fun onLongClick(position: Int) {}
}
)
val dialogBinding = DialogColorBinding.inflate(layoutInflater)
dialogBinding.RecyclerView.adapter = colorAdapter
dialog.setView(dialogBinding.root)
dialog.show()
}
private fun deleteForever() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_selected_notes)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteBaseNotes() }
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun label() {
val baseNote = model.actionMode.getFirstNote()
lifecycleScope.launch {
val labels = model.getAllLabels()
if (labels.isNotEmpty()) {
displaySelectLabelsDialog(labels, baseNote)
} else {
model.actionMode.close(true)
navigateWithAnimation(R.id.Labels)
}
}
}
private fun displaySelectLabelsDialog(labels: Array<String>, baseNote: BaseNote) {
val checkedPositions =
BooleanArray(labels.size) { index -> baseNote.labels.contains(labels[index]) }
MaterialAlertDialogBuilder(this)
.setTitle(R.string.labels)
.setNegativeButton(R.string.cancel, null)
.setMultiChoiceItems(labels, checkedPositions) { _, which, isChecked ->
checkedPositions[which] = isChecked
}
.setPositiveButton(R.string.save) { _, _ ->
val new = ArrayList<String>()
checkedPositions.forEachIndexed { index, checked ->
if (checked) {
val label = labels[index]
new.add(label)
}
}
model.updateBaseNoteLabels(new, baseNote.id)
}
.show()
}
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)
intent.setDataAndType(uri, mimeType)
intent.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)
intent.type = mimeType
intent.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)
intent.type = mimeType
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.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
navController = navHostFragment.navController
configuration = AppBarConfiguration(binding.NavigationView.menu, binding.DrawerLayout)
setupActionBarWithNavController(navController, configuration)
var fragmentIdToLoad: Int? = null
binding.NavigationView.setNavigationItemSelectedListener { item ->
fragmentIdToLoad = item.itemId
binding.DrawerLayout.closeDrawer(GravityCompat.START)
return@setNavigationItemSelectedListener true
}
binding.DrawerLayout.addDrawerListener(
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerClosed(drawerView: View) {
if (
fragmentIdToLoad != null &&
navController.currentDestination?.id != fragmentIdToLoad
) {
navigateWithAnimation(requireNotNull(fragmentIdToLoad))
}
}
}
)
navController.addOnDestinationChangedListener { _, destination, _ ->
fragmentIdToLoad = destination.id
binding.NavigationView.setCheckedItem(destination.id)
handleDestinationChange(destination)
}
}
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.visibility = View.VISIBLE
binding.EnterSearchKeyword.requestFocus()
inputManager.showSoftInput(binding.EnterSearchKeyword, InputMethodManager.SHOW_IMPLICIT)
} else {
binding.EnterSearchKeyword.visibility = View.GONE
inputManager.hideSoftInputFromWindow(binding.EnterSearchKeyword.windowToken, 0)
}
}
private fun navigateWithAnimation(id: Int) {
val options = navOptions {
launchSingleTop = true
anim {
exit = androidx.navigation.ui.R.anim.nav_default_exit_anim
enter = androidx.navigation.ui.R.anim.nav_default_enter_anim
popExit = androidx.navigation.ui.R.anim.nav_default_pop_exit_anim
popEnter = androidx.navigation.ui.R.anim.nav_default_pop_enter_anim
}
popUpTo(navController.graph.startDestination) { inclusive = false }
}
navController.navigate(id, null, options)
}
private fun setupSearch() {
binding.EnterSearchKeyword.setText(model.keyword)
binding.EnterSearchKeyword.doAfterTextChanged { text ->
model.keyword = requireNotNull(text).trim().toString()
}
}
companion object {
private const val REQUEST_EXPORT_FILE = 10
}
}

View file

@ -0,0 +1,118 @@
package com.omgodse.notally.activities
import android.os.Build
import android.view.MenuItem
import android.view.inputmethod.InputMethodManager
import com.omgodse.notally.R
import com.omgodse.notally.changehistory.ChangeHistory
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.miscellaneous.setOnNextAction
import com.omgodse.notally.preferences.ListItemSorting
import com.omgodse.notally.preferences.Preferences
import com.omgodse.notally.recyclerview.ListItemNoSortCallback
import com.omgodse.notally.recyclerview.ListItemSortedByCheckedCallback
import com.omgodse.notally.recyclerview.ListItemSortedList
import com.omgodse.notally.recyclerview.ListManager
import com.omgodse.notally.recyclerview.adapter.MakeListAdapter
import com.omgodse.notally.recyclerview.toMutableList
import com.omgodse.notally.room.Type
import com.omgodse.notally.widget.WidgetProvider
class MakeList : NotallyActivity(Type.LIST) {
private lateinit var adapter: MakeListAdapter
private lateinit var items: ListItemSortedList
private lateinit var listManager: ListManager
override suspend fun saveNote() {
model.saveNote(items.toMutableList())
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
}
override fun setupToolbar() {
super.setupToolbar()
binding.Toolbar.menu.add(
1,
R.string.delete_checked_items,
R.drawable.delete_all,
MenuItem.SHOW_AS_ACTION_IF_ROOM,
) {
listManager.deleteCheckedItems()
}
binding.Toolbar.menu.add(
1,
R.string.check_all_items,
R.drawable.checkbox_fill,
MenuItem.SHOW_AS_ACTION_IF_ROOM,
) {
listManager.changeCheckedForAll(true)
}
binding.Toolbar.menu.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) {
binding.Toolbar.menu.setGroupDividerEnabled(true)
}
}
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 (model.isNewNote) {
if (model.items.isEmpty()) {
listManager.add(pushChange = false)
}
}
}
override fun setupListeners() {
super.setupListeners()
binding.AddItem.setOnClickListener { listManager.add() }
}
override fun setStateFromModel() {
super.setStateFromModel()
val elevation = resources.displayMetrics.density * 2
listManager =
ListManager(
binding.RecyclerView,
changeHistory,
preferences,
getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager,
)
adapter =
MakeListAdapter(
model.textSize,
elevation,
Preferences.getInstance(application),
listManager,
)
val sortCallback =
when (preferences.listItemSorting.value) {
ListItemSorting.autoSortByChecked -> ListItemSortedByCheckedCallback(adapter)
else -> ListItemNoSortCallback(adapter)
}
items = ListItemSortedList(sortCallback)
if (sortCallback is ListItemSortedByCheckedCallback) {
sortCallback.setList(items)
}
items.addAll(model.items)
adapter.setList(items)
binding.RecyclerView.adapter = adapter
listManager.adapter = adapter
listManager.initList(items)
}
}

View file

@ -0,0 +1,515 @@
package com.omgodse.notally.activities
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.Editable
import android.util.TypedValue
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.widget.Toast
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.omgodse.notally.R
import com.omgodse.notally.changehistory.ChangeHistory
import com.omgodse.notally.databinding.ActivityNotallyBinding
import com.omgodse.notally.databinding.DialogProgressBinding
import com.omgodse.notally.image.ImageError
import com.omgodse.notally.miscellaneous.Constants
import com.omgodse.notally.miscellaneous.Operations
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.miscellaneous.displayFormattedTimestamp
import com.omgodse.notally.preferences.Preferences
import com.omgodse.notally.preferences.TextSize
import com.omgodse.notally.recyclerview.adapter.AudioAdapter
import com.omgodse.notally.recyclerview.adapter.ErrorAdapter
import com.omgodse.notally.recyclerview.adapter.PreviewImageAdapter
import com.omgodse.notally.room.Audio
import com.omgodse.notally.room.Folder
import com.omgodse.notally.room.Image
import com.omgodse.notally.room.Type
import com.omgodse.notally.viewmodels.NotallyModel
import com.omgodse.notally.widget.WidgetProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
abstract class NotallyActivity(private val type: Type) : AppCompatActivity() {
internal lateinit var binding: ActivityNotallyBinding
internal val model: NotallyModel by viewModels()
internal lateinit var preferences: Preferences
internal lateinit var changeHistory: ChangeHistory
override fun finish() {
lifecycleScope.launch(Dispatchers.Main) {
saveNote()
super.finish()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong("id", model.id)
lifecycleScope.launch { saveNote() }
}
open suspend fun saveNote() {
model.saveNote()
WidgetProvider.sendBroadcast(application, longArrayOf(model.id))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
preferences = Preferences.getInstance(application)
model.type = type
initialiseBinding()
setContentView(binding.root)
lifecycleScope.launch {
if (model.isFirstInstance) {
val persistedId = savedInstanceState?.getLong("id")
val selectedId = intent.getLongExtra(Constants.SelectedBaseNote, 0L)
val id = persistedId ?: selectedId
model.setState(id)
if (model.isNewNote && intent.action == Intent.ACTION_SEND) {
handleSharedNote()
}
model.isFirstInstance = false
}
setupToolbar()
setupListeners()
setStateFromModel()
configureUI()
binding.ScrollView.visibility = View.VISIBLE
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
REQUEST_ADD_IMAGES -> {
val uri = data?.data
val clipData = data?.clipData
if (uri != null) {
val uris = arrayOf(uri)
model.addImages(uris)
} else if (clipData != null) {
val uris =
Array(clipData.itemCount) { index -> clipData.getItemAt(index).uri }
model.addImages(uris)
}
}
REQUEST_VIEW_IMAGES -> {
val list = data?.getParcelableArrayListExtra<Image>(ViewImage.DELETED_IMAGES)
if (!list.isNullOrEmpty()) {
model.deleteImages(list)
}
}
REQUEST_SELECT_LABELS -> {
val list = data?.getStringArrayListExtra(SelectLabels.SELECTED_LABELS)
if (list != null && list != model.labels) {
model.setLabels(list)
Operations.bindLabels(binding.LabelGroup, model.labels, model.textSize)
}
}
REQUEST_RECORD_AUDIO -> model.addAudio()
REQUEST_PLAY_AUDIO -> {
val audio = data?.getParcelableExtra<Audio>(PlayAudio.AUDIO)
if (audio != null) {
model.deleteAudio(audio)
}
}
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_NOTIFICATION_PERMISSION -> selectImages()
REQUEST_AUDIO_PERMISSION -> {
if (
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
recordAudio()
} else handleRejection()
}
}
}
protected open fun initActionManager(undo: MenuItem, redo: MenuItem) {
changeHistory = ChangeHistory {
undo.isEnabled = changeHistory.canUndo()
redo.isEnabled = changeHistory.canRedo()
}
}
protected open fun setupToolbar() {
binding.Toolbar.setNavigationOnClickListener { finish() }
val menu = binding.Toolbar.menu
val pin =
menu.add(R.string.pin, R.drawable.pin, MenuItem.SHOW_AS_ACTION_ALWAYS) { item ->
pin(item)
}
bindPinned(pin)
val undo =
menu.add(R.string.undo, R.drawable.undo, MenuItem.SHOW_AS_ACTION_ALWAYS) {
changeHistory.undo()
}
val redo =
menu.add(R.string.redo, R.drawable.redo, MenuItem.SHOW_AS_ACTION_ALWAYS) {
changeHistory.redo()
}
initActionManager(undo, redo)
undo.isEnabled = changeHistory.canUndo()
redo.isEnabled = changeHistory.canRedo()
menu.add(R.string.share, R.drawable.share) { share() }
menu.add(R.string.labels, R.drawable.label) { label() }
menu.add(R.string.add_images, R.drawable.add_images) { checkNotificationPermission() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
menu.add(R.string.record_audio, R.drawable.record_audio) { checkAudioPermission() }
}
when (model.folder) {
Folder.NOTES -> {
menu.add(R.string.delete, R.drawable.delete) { delete() }
menu.add(R.string.archive, R.drawable.archive) { archive() }
}
Folder.DELETED -> {
menu.add(R.string.restore, R.drawable.restore) { restore() }
menu.add(R.string.delete_forever, R.drawable.delete) { deleteForever() }
}
Folder.ARCHIVED -> {
menu.add(R.string.delete, R.drawable.delete) { delete() }
menu.add(R.string.unarchive, R.drawable.unarchive) { restore() }
}
}
}
abstract fun configureUI()
open fun setupListeners() {
binding.EnterTitle.doAfterTextChanged { text ->
model.title = requireNotNull(text).trim().toString()
}
}
open fun setStateFromModel() {
binding.DateCreated.displayFormattedTimestamp(model.timestamp, preferences.dateFormat.value)
binding.EnterTitle.setText(model.title)
Operations.bindLabels(binding.LabelGroup, model.labels, model.textSize)
setColor()
}
private fun handleSharedNote() {
val title = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val string = intent.getStringExtra(Intent.EXTRA_TEXT)
val charSequence = intent.getCharSequenceExtra(Operations.extraCharSequence)
val body = charSequence ?: string
if (body != null) {
model.body = Editable.Factory.getInstance().newEditable(body)
}
if (title != null) {
model.title = title
}
}
@RequiresApi(24)
private fun checkAudioPermission() {
val permission = Manifest.permission.RECORD_AUDIO
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
if (shouldShowRequestPermissionRationale(permission)) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.please_grant_notally_audio)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.continue_) { _, _ ->
requestPermissions(arrayOf(permission), REQUEST_AUDIO_PERMISSION)
}
.show()
} else requestPermissions(arrayOf(permission), REQUEST_AUDIO_PERMISSION)
} else recordAudio()
}
private fun checkNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = Manifest.permission.POST_NOTIFICATIONS
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
if (shouldShowRequestPermissionRationale(permission)) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.please_grant_notally_notification)
.setNegativeButton(R.string.cancel) { _, _ -> selectImages() }
.setPositiveButton(R.string.continue_) { _, _ ->
requestPermissions(arrayOf(permission), REQUEST_NOTIFICATION_PERMISSION)
}
.setOnDismissListener { selectImages() }
.show()
} else requestPermissions(arrayOf(permission), REQUEST_NOTIFICATION_PERMISSION)
} else selectImages()
} else selectImages()
}
private fun recordAudio() {
if (model.audioRoot != null) {
val intent = Intent(this, RecordAudio::class.java)
startActivityForResult(intent, REQUEST_RECORD_AUDIO)
} else Toast.makeText(this, R.string.insert_an_sd_card_audio, Toast.LENGTH_LONG).show()
}
private fun handleRejection() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.to_record_audio)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.settings) { _, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:${packageName}")
startActivity(intent)
}
.show()
}
private fun selectImages() {
if (model.imageRoot != null) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
intent.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult(intent, REQUEST_ADD_IMAGES)
} else Toast.makeText(this, R.string.insert_an_sd_card_images, Toast.LENGTH_LONG).show()
}
private fun share() {
val body =
when (type) {
Type.NOTE -> model.body
Type.LIST -> Operations.getBody(model.items!!.toMutableList())
}
Operations.shareNote(this, model.title, body)
}
private fun label() {
val intent = Intent(this, SelectLabels::class.java)
intent.putStringArrayListExtra(SelectLabels.SELECTED_LABELS, model.labels)
startActivityForResult(intent, REQUEST_SELECT_LABELS)
}
private fun delete() {
model.folder = Folder.DELETED
finish()
}
private fun restore() {
model.folder = Folder.NOTES
finish()
}
private fun archive() {
model.folder = Folder.ARCHIVED
finish()
}
private fun deleteForever() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_note_forever)
.setPositiveButton(R.string.delete) { _, _ ->
lifecycleScope.launch {
model.deleteBaseNote()
super.finish()
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun pin(item: MenuItem) {
model.pinned = !model.pinned
bindPinned(item)
}
private fun setupImages() {
val adapter =
PreviewImageAdapter(model.imageRoot) { position ->
val intent = Intent(this, ViewImage::class.java)
intent.putExtra(ViewImage.POSITION, position)
intent.putExtra(Constants.SelectedBaseNote, model.id)
startActivityForResult(intent, REQUEST_VIEW_IMAGES)
}
adapter.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
binding.ImagePreview.scrollToPosition(positionStart)
}
}
)
binding.ImagePreview.setHasFixedSize(true)
binding.ImagePreview.adapter = adapter
binding.ImagePreview.layoutManager =
LinearLayoutManager(this, RecyclerView.HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(binding.ImagePreview)
model.images.observe(this) { list ->
adapter.submitList(list)
binding.ImagePreview.isVisible = list.isNotEmpty()
}
val dialogBinding = DialogProgressBinding.inflate(layoutInflater)
val dialog =
MaterialAlertDialogBuilder(this)
.setTitle(R.string.adding_images)
.setView(dialogBinding.root)
.setCancelable(false)
.create()
model.addingImages.observe(this) { progress ->
if (progress.inProgress) {
dialog.show()
dialogBinding.ProgressBar.max = progress.total
dialogBinding.ProgressBar.setProgressCompat(progress.current, true)
dialogBinding.Count.text =
getString(R.string.count, progress.current, progress.total)
} else dialog.dismiss()
}
model.eventBus.observe(this) { event ->
event.handle { errors -> displayImageErrors(errors) }
}
}
private fun displayImageErrors(errors: List<ImageError>) {
val recyclerView = RecyclerView(this)
recyclerView.layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
recyclerView.adapter = ErrorAdapter(errors)
recyclerView.layoutManager = LinearLayoutManager(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
recyclerView.scrollIndicators =
View.SCROLL_INDICATOR_TOP or View.SCROLL_INDICATOR_BOTTOM
}
val title = resources.getQuantityString(R.plurals.cant_add_images, errors.size, errors.size)
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setView(recyclerView)
.setNegativeButton(R.string.cancel, null)
.setCancelable(false)
.show()
}
private fun setupAudios() {
val adapter = AudioAdapter { position: Int ->
if (position != -1) {
val audio = model.audios.value[position]
val intent = Intent(this, PlayAudio::class.java)
intent.putExtra(PlayAudio.AUDIO, audio)
startActivityForResult(intent, REQUEST_PLAY_AUDIO)
}
}
binding.AudioRecyclerView.adapter = adapter
model.audios.observe(this) { list ->
adapter.submitList(list)
binding.AudioHeader.isVisible = list.isNotEmpty()
binding.AudioRecyclerView.isVisible = list.isNotEmpty()
}
}
private fun setColor() {
val color = Operations.extractColor(model.color, this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
window.statusBarColor = color
}
binding.root.setBackgroundColor(color)
binding.RecyclerView.setBackgroundColor(color)
binding.Toolbar.backgroundTintList = ColorStateList.valueOf(color)
}
private fun initialiseBinding() {
binding = ActivityNotallyBinding.inflate(layoutInflater)
when (type) {
Type.NOTE -> {
binding.AddItem.visibility = View.GONE
binding.RecyclerView.visibility = View.GONE
}
Type.LIST -> {
binding.EnterBody.visibility = View.GONE
}
}
val title = TextSize.getEditTitleSize(model.textSize)
val date = TextSize.getDisplayBodySize(model.textSize)
val body = TextSize.getEditBodySize(model.textSize)
binding.EnterTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, title)
binding.DateCreated.setTextSize(TypedValue.COMPLEX_UNIT_SP, date)
binding.EnterBody.setTextSize(TypedValue.COMPLEX_UNIT_SP, body)
setupImages()
setupAudios()
binding.root.isSaveFromParentEnabled = false
}
private fun bindPinned(item: MenuItem) {
val icon: Int
val title: Int
if (model.pinned) {
icon = R.drawable.unpin
title = R.string.unpin
} else {
icon = R.drawable.pin
title = R.string.pin
}
item.setTitle(title)
item.setIcon(icon)
}
companion object {
private const val REQUEST_ADD_IMAGES = 30
private const val REQUEST_VIEW_IMAGES = 31
private const val REQUEST_NOTIFICATION_PERMISSION = 32
private const val REQUEST_SELECT_LABELS = 33
private const val REQUEST_RECORD_AUDIO = 34
private const val REQUEST_PLAY_AUDIO = 35
private const val REQUEST_AUDIO_PERMISSION = 36
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.activity.note
package com.omgodse.notally.activities
import android.content.ComponentName
import android.content.Intent
@ -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.appcompat.app.AppCompatActivity
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.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 com.omgodse.notally.R
import com.omgodse.notally.audio.AudioPlayService
import com.omgodse.notally.audio.LocalBinder
import com.omgodse.notally.databinding.ActivityPlayAudioBinding
import com.omgodse.notally.miscellaneous.IO
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.room.Audio
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@ -31,23 +26,20 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
class PlayAudio : AppCompatActivity() {
private var service: AudioPlayService? = null
private lateinit var connection: ServiceConnection
private lateinit var exportFileActivityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var audio: Audio
private lateinit var binding: ActivityPlayAudioBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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)
@ -60,7 +52,7 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
val service = (binder as LocalBinder<AudioPlayService>).getService()
service.initialise(audio)
service.onStateChange = { updateUI(service) }
this@PlayAudioActivity.service = service
this@PlayAudio.service = service
updateUI(service)
}
@ -71,20 +63,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,39 +81,43 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
}
}
private fun setupToolbar(binding: ActivityPlayAudioBinding) {
binding.Toolbar.setNavigationOnClickListener { onBackPressed() }
binding.Toolbar.menu.apply {
add(R.string.share, R.drawable.share) { share() }
add(R.string.save_to_device, R.drawable.save) { saveToDevice() }
add(R.string.delete, R.drawable.delete) { delete() }
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() }
binding.Toolbar.menu.add(R.string.share, R.drawable.share) { share() }
binding.Toolbar.menu.add(R.string.save_to_device, R.drawable.save) { saveToDevice() }
binding.Toolbar.menu.add(R.string.delete, R.drawable.delete) { delete() }
}
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 intent =
Intent(Intent.ACTION_SEND)
.apply {
type = "audio/mp4"
putExtra(Intent.EXTRA_STREAM, uri)
}
.wrapWithChooser(this@PlayAudioActivity)
startActivity(intent)
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "audio/mp4"
intent.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 +125,25 @@ 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)
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.type = "audio/mp4"
intent.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
@ -173,8 +154,7 @@ class PlayAudioActivity : LockedActivity<ActivityPlayAudioBinding>() {
output.close()
}
}
Toast.makeText(this@PlayAudioActivity, R.string.saved_to_device, Toast.LENGTH_LONG)
.show()
Toast.makeText(this@PlayAudio, R.string.saved_to_device, Toast.LENGTH_LONG).show()
}
}
@ -203,6 +183,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

@ -1,33 +1,29 @@
package com.philkes.notallyx.presentation.activity.note
package com.omgodse.notally.activities
import android.content.ComponentName
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 androidx.appcompat.app.AppCompatActivity
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.audio.AudioRecordService
import com.philkes.notallyx.utils.audio.LocalBinder
import com.philkes.notallyx.utils.audio.Status
import com.philkes.notallyx.utils.getTempAudioFile
import com.omgodse.notally.R
import com.omgodse.notally.audio.AudioRecordService
import com.omgodse.notally.audio.LocalBinder
import com.omgodse.notally.audio.Status
import com.omgodse.notally.databinding.ActivityRecordAudioBinding
import com.omgodse.notally.miscellaneous.IO
@RequiresApi(24)
class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
class RecordAudio : AppCompatActivity() {
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)
binding = ActivityRecordAudioBinding.inflate(layoutInflater)
val binding = ActivityRecordAudioBinding.inflate(layoutInflater)
setContentView(binding.root)
val intent = Intent(this, AudioRecordService::class.java)
@ -35,9 +31,10 @@ class RecordAudioActivity : LockedActivity<ActivityRecordAudioBinding>() {
connection =
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 +45,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 +61,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 +76,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 +103,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

@ -1,23 +1,26 @@
package com.philkes.notallyx.presentation.activity.note
package com.omgodse.notally.activities
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.DividerItemDecoration
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.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.omgodse.notally.R
import com.omgodse.notally.databinding.ActivityLabelBinding
import com.omgodse.notally.databinding.DialogInputBinding
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.recyclerview.adapter.SelectableLabelAdapter
import com.omgodse.notally.room.Label
import com.omgodse.notally.viewmodels.LabelModel
class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
class SelectLabels : AppCompatActivity() {
private val model: LabelModel by viewModels()
private lateinit var binding: ActivityLabelBinding
private lateinit var selectedLabels: ArrayList<String>
@ -26,12 +29,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,14 +43,12 @@ 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() {
binding.Toolbar.apply {
setNavigationOnClickListener { finish() }
menu.add(R.string.add_label, R.drawable.add) { addLabel() }
}
binding.Toolbar.setNavigationOnClickListener { finish() }
binding.Toolbar.menu.add(R.string.add_label, R.drawable.add) { addLabel() }
}
private fun addLabel() {
@ -56,26 +57,28 @@ 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() {
val labelAdapter = SelectableLabelAdapter(selectedLabels)
labelAdapter.onChecked = { position, checked ->
val adapter = SelectableLabelAdapter(selectedLabels)
adapter.onChecked = { position, checked ->
if (position != -1) {
val label = labelAdapter.currentList[position]
val label = adapter.currentList[position]
if (checked) {
if (!selectedLabels.contains(label)) {
selectedLabels.add(label)
@ -84,16 +87,12 @@ class SelectLabelsActivity : LockedActivity<ActivityLabelBinding>() {
}
}
binding.MainListView.apply {
setHasFixedSize(true)
adapter = labelAdapter
addItemDecoration(
DividerItemDecoration(this@SelectLabelsActivity, RecyclerView.VERTICAL)
)
}
binding.RecyclerView.setHasFixedSize(true)
binding.RecyclerView.adapter = adapter
binding.RecyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
baseModel.labels.observe(this) { labels ->
labelAdapter.submitList(labels)
model.labels.observe(this) { labels ->
adapter.submitList(labels)
if (labels.isEmpty()) {
binding.EmptyState.visibility = View.VISIBLE
} else binding.EmptyState.visibility = View.INVISIBLE
@ -101,6 +100,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

@ -0,0 +1,187 @@
package com.omgodse.notally.activities
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.text.style.CharacterStyle
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import android.text.style.URLSpan
import android.util.Patterns
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.core.text.getSpans
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.omgodse.notally.LinkMovementMethod
import com.omgodse.notally.R
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.miscellaneous.createTextWatcherWithHistory
import com.omgodse.notally.miscellaneous.setOnNextAction
import com.omgodse.notally.room.Type
class TakeNote : NotallyActivity(Type.NOTE) {
private lateinit var enterBodyTextWatcher: TextWatcher
override fun configureUI() {
binding.EnterTitle.setOnNextAction { binding.EnterBody.requestFocus() }
setupEditor()
if (model.isNewNote) {
binding.EnterBody.requestFocus()
}
}
override fun setupListeners() {
super.setupListeners()
enterBodyTextWatcher = run {
binding.EnterBody.createTextWatcherWithHistory(changeHistory) { text: String ->
model.body = Editable.Factory.getInstance().newEditable(text)
}
}
binding.EnterBody.addTextChangedListener(enterBodyTextWatcher)
}
override fun setStateFromModel() {
super.setStateFromModel()
updateEditText()
}
private fun updateEditText() {
binding.EnterBody.removeTextChangedListener(enterBodyTextWatcher)
binding.EnterBody.text = model.body
binding.EnterBody.addTextChangedListener(enterBodyTextWatcher)
}
private fun setupEditor() {
setupMovementMethod()
binding.EnterBody.customSelectionActionModeCallback =
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 {
if (menu != null) {
menu.add(R.string.bold, 0) {
applySpan(StyleSpan(Typeface.BOLD))
mode?.finish()
}
menu.add(R.string.link, 0) {
applySpan(URLSpan(null))
mode?.finish()
}
menu.add(R.string.italic, 0) {
applySpan(StyleSpan(Typeface.ITALIC))
mode?.finish()
}
menu.add(R.string.monospace, 0) {
applySpan(TypefaceSpan("monospace"))
mode?.finish()
}
menu.add(R.string.strikethrough, 0) {
applySpan(StrikethroughSpan())
mode?.finish()
}
menu.add(R.string.clear_formatting, 0) {
removeSpans()
mode?.finish()
}
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
binding.EnterBody.isActionModeOn = false
}
}
}
private fun setupMovementMethod() {
val items = arrayOf(getString(R.string.edit), getString(R.string.open_link))
val movementMethod = LinkMovementMethod { span ->
MaterialAlertDialogBuilder(this)
.setItems(items) { dialog, which ->
if (which == 1) {
val spanStart = binding.EnterBody.text?.getSpanStart(span)
val spanEnd = binding.EnterBody.text?.getSpanEnd(span)
ifBothNotNullAndInvalid(spanStart, spanEnd) { start, end ->
val text = binding.EnterBody.text?.substring(start, end)
if (text != null) {
val link = getURLFrom(text)
val uri = Uri.parse(link)
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()
}
binding.EnterBody.movementMethod = movementMethod
}
private fun removeSpans() {
val selectionEnd = binding.EnterBody.selectionEnd
val selectionStart = binding.EnterBody.selectionStart
ifBothNotNullAndInvalid(selectionStart, selectionEnd) { start, end ->
binding.EnterBody.text?.getSpans<CharacterStyle>(start, end)?.forEach { span ->
binding.EnterBody.text?.removeSpan(span)
}
}
}
private fun applySpan(span: Any) {
val selectionEnd = binding.EnterBody.selectionEnd
val selectionStart = binding.EnterBody.selectionStart
ifBothNotNullAndInvalid(selectionStart, selectionEnd) { start, end ->
binding.EnterBody.text?.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
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)
}
}
companion object {
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

@ -0,0 +1,212 @@
package com.omgodse.notally.activities
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.omgodse.notally.R
import com.omgodse.notally.databinding.ActivityViewImageBinding
import com.omgodse.notally.miscellaneous.Constants
import com.omgodse.notally.miscellaneous.IO
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.recyclerview.adapter.ImageAdapter
import com.omgodse.notally.room.Converters
import com.omgodse.notally.room.Image
import com.omgodse.notally.room.NotallyDatabase
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ViewImage : AppCompatActivity() {
private var currentImage: Image? = null
private lateinit var deletedImages: ArrayList<Image>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityViewImageBinding.inflate(layoutInflater)
setContentView(binding.root)
val savedList = savedInstanceState?.getParcelableArrayList<Image>(DELETED_IMAGES)
deletedImages = savedList ?: ArrayList()
val result = Intent()
result.putExtra(DELETED_IMAGES, deletedImages)
setResult(RESULT_OK, result)
val savedImage = savedInstanceState?.getParcelable<Image>(CURRENT_IMAGE)
if (savedImage != null) {
currentImage = savedImage
}
binding.RecyclerView.setHasFixedSize(true)
binding.RecyclerView.layoutManager =
LinearLayoutManager(this, RecyclerView.HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(binding.RecyclerView)
val initial = intent.getIntExtra(POSITION, 0)
binding.RecyclerView.scrollToPosition(initial)
lifecycleScope.launch {
val database = NotallyDatabase.getDatabase(application)
val id = intent.getLongExtra(Constants.SelectedBaseNote, 0)
val json = withContext(Dispatchers.IO) { database.getBaseNoteDao().getImages(id) }
val original = Converters.jsonToImages(json)
val images = ArrayList<Image>(original.size)
original.filterNotTo(images) { image -> deletedImages.contains(image) }
val mediaRoot = IO.getExternalImagesDirectory(application)
val adapter = ImageAdapter(mediaRoot, images)
binding.RecyclerView.adapter = adapter
setupToolbar(binding, adapter)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable(CURRENT_IMAGE, currentImage)
outState.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.RecyclerView.layoutManager as LinearLayoutManager
adapter.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
val position = layoutManager.findFirstVisibleItemPosition()
binding.Toolbar.title = "${position + 1} / ${adapter.itemCount}"
}
}
)
binding.RecyclerView.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val position = layoutManager.findFirstVisibleItemPosition()
binding.Toolbar.title = "${position + 1} / ${adapter.itemCount}"
}
}
)
binding.Toolbar.menu.add(R.string.share, R.drawable.share) {
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
if (position != -1) {
val image = adapter.items[position]
share(image)
}
}
binding.Toolbar.menu.add(R.string.save_to_device, R.drawable.save) {
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
if (position != -1) {
val image = adapter.items[position]
saveToDevice(image)
}
}
binding.Toolbar.menu.add(R.string.delete, R.drawable.delete) {
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
if (position != -1) {
delete(position, adapter)
}
}
}
private fun share(image: Image) {
val mediaRoot = IO.getExternalImagesDirectory(application)
val file = if (mediaRoot != null) File(mediaRoot, image.name) else null
if (file != null && file.exists()) {
val uri = FileProvider.getUriForFile(this, "$packageName.provider", file)
val intent = Intent(Intent.ACTION_SEND)
intent.type = image.mimeType
intent.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
intent.clipData = ClipData.newRawUri(null, uri)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, null)
startActivity(chooser)
}
}
private fun saveToDevice(image: Image) {
val mediaRoot = IO.getExternalImagesDirectory(application)
val file = if (mediaRoot != null) File(mediaRoot, image.name) else null
if (file != null && file.exists()) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.type = image.mimeType
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.putExtra(Intent.EXTRA_TITLE, "Notally Image")
currentImage = image
startActivityForResult(intent, REQUEST_EXPORT_FILE)
}
}
private fun writeImageToUri(uri: Uri) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val mediaRoot = IO.getExternalImagesDirectory(application)
val file =
if (mediaRoot != null) File(mediaRoot, requireNotNull(currentImage).name)
else null
if (file != null && file.exists()) {
val output = contentResolver.openOutputStream(uri) as FileOutputStream
output.channel.truncate(0)
val input = FileInputStream(file)
input.copyTo(output)
input.close()
output.close()
}
}
Toast.makeText(this@ViewImage, R.string.saved_to_device, Toast.LENGTH_LONG).show()
}
}
private fun delete(position: Int, adapter: ImageAdapter) {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.delete_image_forever)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
val image = adapter.items.removeAt(position)
deletedImages.add(image)
adapter.notifyItemRemoved(position)
if (adapter.items.isEmpty()) {
finish()
}
}
.show()
}
companion object {
const val POSITION = "POSITION"
const val CURRENT_IMAGE = "CURRENT_IMAGE"
const val DELETED_IMAGES = "DELETED_IMAGES"
private const val REQUEST_EXPORT_FILE = 40
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.note.audio
package com.omgodse.notally.audio
import android.annotation.SuppressLint
import android.content.Context
@ -9,7 +9,7 @@ import android.view.View
import android.widget.RelativeLayout
import android.widget.TextView
import com.google.android.material.slider.Slider
import com.philkes.notallyx.R
import com.omgodse.notally.R
class AudioControlView(context: Context, attrs: AttributeSet) : RelativeLayout(context, attrs) {
@ -22,7 +22,7 @@ class AudioControlView(context: Context, attrs: AttributeSet) : RelativeLayout(c
private var running = false
private var base = 0L
private var duration: Long? = null
private var duration = 0L
private val recycle = StringBuilder(8)
private var seeking = false
@ -58,10 +58,10 @@ class AudioControlView(context: Context, attrs: AttributeSet) : RelativeLayout(c
setCurrentPosition(0)
}
fun setDuration(milliseconds: Long?) {
fun setDuration(milliseconds: Long) {
duration = milliseconds
progress.valueTo = milliseconds?.toFloat() ?: Int.MAX_VALUE.toFloat()
length.text = milliseconds?.let { DateUtils.formatElapsedTime(recycle, it / 1000) } ?: "-"
progress.valueTo = milliseconds.toFloat()
length.text = DateUtils.formatElapsedTime(recycle, milliseconds / 1000)
}
fun setCurrentPosition(position: Int) {
@ -78,10 +78,8 @@ class AudioControlView(context: Context, attrs: AttributeSet) : RelativeLayout(c
@Synchronized
private fun updateComponents(now: Long) {
var milliseconds = now - base
duration?.let {
if (milliseconds > it) {
milliseconds = it
}
if (milliseconds > duration) {
milliseconds = duration
}
chronometer.text = DateUtils.formatElapsedTime(recycle, milliseconds / 1000)
if (!seeking) {

View file

@ -1,12 +1,12 @@
package com.philkes.notallyx.utils.audio
package com.omgodse.notally.audio
import android.app.Service
import android.content.Intent
import android.media.MediaPlayer
import android.os.IBinder
import com.philkes.notallyx.data.model.Audio
import com.philkes.notallyx.utils.getExternalAudioDirectory
import com.philkes.notallyx.utils.log
import com.omgodse.notally.miscellaneous.IO
import com.omgodse.notally.miscellaneous.Operations
import com.omgodse.notally.room.Audio
import java.io.File
class AudioPlayService : Service() {
@ -21,18 +21,16 @@ class AudioPlayService : Service() {
override fun onCreate() {
super.onCreate()
player =
MediaPlayer().apply {
setOnPreparedListener { setState(PREPARED) }
setOnCompletionListener { setState(COMPLETED) }
setOnSeekCompleteListener { setState(stateBeforeSeeking) }
setOnErrorListener { _, what, extra ->
errorType = what
errorCode = extra
setState(ERROR)
return@setOnErrorListener true
}
}
player = MediaPlayer()
player.setOnPreparedListener { setState(PREPARED) }
player.setOnCompletionListener { setState(COMPLETED) }
player.setOnSeekCompleteListener { setState(stateBeforeSeeking) }
player.setOnErrorListener { _, what, extra ->
errorType = what
errorCode = extra
setState(ERROR)
return@setOnErrorListener true
}
}
override fun onDestroy() {
@ -46,7 +44,7 @@ class AudioPlayService : Service() {
fun initialise(audio: Audio) {
if (state == IDLE) {
val audioRoot = application.getExternalAudioDirectory()
val audioRoot = IO.getExternalAudioDirectory(application)
if (audioRoot != null) {
try {
val file = File(audioRoot, audio.name)
@ -55,7 +53,7 @@ class AudioPlayService : Service() {
player.prepareAsync()
} catch (exception: Exception) {
setIOError()
application.log(TAG, throwable = exception)
Operations.log(application, exception)
}
} else setIOError()
}
@ -112,7 +110,6 @@ class AudioPlayService : Service() {
}
companion object {
private const val TAG = "AudioPlayService"
const val IDLE = 0
const val INITIALISED = 1
const val PREPARED = 2

View file

@ -0,0 +1,135 @@
package com.omgodse.notally.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.MediaRecorder
import android.os.Build
import android.os.SystemClock
import androidx.annotation.RequiresApi
import com.omgodse.notally.R
import com.omgodse.notally.activities.RecordAudio
import com.omgodse.notally.audio.Status.PAUSED
import com.omgodse.notally.audio.Status.READY
import com.omgodse.notally.audio.Status.RECORDING
import com.omgodse.notally.miscellaneous.IO
@RequiresApi(24)
class AudioRecordService : Service() {
var status = READY
private var lastStart = 0L
private var audioDuration = 0L
private lateinit var recorder: MediaRecorder
private lateinit var manager: NotificationManager
private lateinit var builder: Notification.Builder
override fun onCreate() {
manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
builder = Notification.Builder(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = "com.omgodse.audio"
val channel =
NotificationChannel(
channelId,
"Audio Recordings",
NotificationManager.IMPORTANCE_HIGH,
)
manager.createNotificationChannel(channel)
builder.setChannelId(channelId)
}
builder.setSmallIcon(R.drawable.record_audio)
builder.setOnlyAlertOnce(true)
/*
Prevent user from dismissing notification in Android 13 (33) and above
https://developer.android.com/guide/components/foreground-services#user-dismiss-notification
*/
builder.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
*/
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
}
val intent = Intent(this, RecordAudio::class.java)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
builder.setContentIntent(pendingIntent)
startForeground(2, buildNotification())
recorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else MediaRecorder()
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
val output = IO.getTempAudioFile(this)
recorder.setOutputFile(output.path)
recorder.prepare()
}
override fun onDestroy() {
recorder.release()
}
override fun onBind(intent: Intent?) = LocalBinder(this)
fun start() {
recorder.start()
status = RECORDING
lastStart = SystemClock.elapsedRealtime()
manager.notify(2, buildNotification())
}
fun resume() {
recorder.resume()
status = RECORDING
lastStart = SystemClock.elapsedRealtime()
manager.notify(2, buildNotification())
}
fun pause() {
recorder.pause()
status = PAUSED
audioDuration += SystemClock.elapsedRealtime() - lastStart
lastStart = 0L
manager.notify(2, buildNotification())
}
fun stop() {
recorder.stop()
stopSelf()
}
fun getBase(): Long {
return if (lastStart != 0L) {
lastStart - audioDuration
} else SystemClock.elapsedRealtime() - audioDuration
}
private fun buildNotification(): Notification {
val title =
when (status) {
READY -> getString(R.string.ready_to_record)
PAUSED -> getString(R.string.paused)
RECORDING -> getString(R.string.recording)
}
builder.setContentTitle(title)
builder.setContentText(getString(R.string.tap_for_more_options))
return builder.build()
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.utils.audio
package com.omgodse.notally.audio
import android.app.Service
import android.os.Binder

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.utils.audio
package com.omgodse.notally.audio
enum class Status {
READY,

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.utils.changehistory
package com.omgodse.notally.changehistory
interface Change {
fun redo()

View file

@ -0,0 +1,26 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
class ChangeCheckedForAllChange(
internal val checked: Boolean,
internal val changedPositions: Collection<Int>,
internal val changedPositionsAfterSort: Collection<Int>,
private val listManager: ListManager,
) : Change {
override fun redo() {
listManager.check(checked, changedPositions)
}
override fun undo() {
listManager.check(!checked, changedPositionsAfterSort)
}
override fun toString(): String {
return "ChangeCheckedForAllChange checked: $checked changedPositions: ${
changedPositions.joinToString(
","
)
} changedPositionsAfterSort: ${changedPositionsAfterSort.joinToString(",")}"
}
}

View file

@ -0,0 +1,67 @@
package com.omgodse.notally.changehistory
import android.util.Log
class ChangeHistory(private val onStackChanged: (stackPointer: Int) -> Unit) {
private val TAG = "ChangeHistory"
private val changeStack = ArrayList<Change>()
private var stackPointer = -1
fun push(change: Change) {
popRedos()
changeStack.add(change)
stackPointer++
Log.d(TAG, "addChange: $change")
onStackChanged.invoke(stackPointer)
}
fun redo() {
stackPointer++
if (stackPointer >= changeStack.size) {
throw RuntimeException("There is no Change to redo!")
}
val makeListAction = changeStack[stackPointer]
Log.d(TAG, "redo: $makeListAction")
makeListAction.redo()
onStackChanged.invoke(stackPointer)
}
fun undo() {
if (stackPointer < 0) {
throw RuntimeException("There is no Change to undo!")
}
val makeListAction = changeStack[stackPointer]
Log.d(TAG, "undo: $makeListAction")
makeListAction.undo()
stackPointer--
onStackChanged.invoke(stackPointer)
}
fun canRedo(): Boolean {
return stackPointer >= -1 && stackPointer < changeStack.size - 1
}
fun canUndo(): Boolean {
return stackPointer > -1
}
fun reset() {
stackPointer = -1
changeStack.clear()
}
internal fun lookUp(position: Int = 0): Change {
if (stackPointer - position < 0) {
throw IllegalArgumentException("ChangeHistory only has $stackPointer changes!")
}
return changeStack[stackPointer - position]
}
private fun popRedos() {
while (changeStack.size > stackPointer + 1) {
changeStack.removeAt(stackPointer + 1)
}
// changeStack.subList(stackPointer, changeStack.size).clear()
}
}

View file

@ -0,0 +1,22 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
import com.omgodse.notally.recyclerview.toReadableString
import com.omgodse.notally.room.ListItem
class DeleteCheckedChange(
internal val itemsBeforeDelete: MutableList<ListItem>,
private val listManager: ListManager,
) : Change {
override fun redo() {
listManager.deleteCheckedItems(pushChange = false)
}
override fun undo() {
listManager.updateList(itemsBeforeDelete)
}
override fun toString(): String {
return "DeleteCheckedChange itemsBeforeDelete:\n${itemsBeforeDelete.toReadableString()}"
}
}

View file

@ -0,0 +1,24 @@
package com.omgodse.notally.changehistory
import android.text.TextWatcher
import android.widget.EditText
class EditTextChange(
private val editText: EditText,
textBefore: String,
textAfter: String,
private val listener: TextWatcher,
private val updateModel: (newValue: String) -> Unit,
) : ValueChange<String>(textAfter, textBefore) {
private val cursorPosition = editText.selectionStart
override fun update(value: String, isUndo: Boolean) {
updateModel.invoke(value)
editText.removeTextChangedListener(listener)
editText.setText(value)
editText.requestFocus()
editText.setSelection(Math.max(0, cursorPosition - (if (isUndo) 1 else 0)))
editText.addTextChangedListener(listener)
}
}

View file

@ -0,0 +1,27 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
import com.omgodse.notally.room.ListItem
class ListAddChange(
position: Int,
internal val positionAfterAdd: Int,
internal val itemBeforeInsert: ListItem,
private val listManager: ListManager,
) : ListChange(position) {
override fun redo() {
listManager.add(position, item = itemBeforeInsert, pushChange = false)
}
override fun undo() {
listManager.delete(
positionAfterAdd,
childrenToDelete = itemBeforeInsert.children,
pushChange = false,
)
}
override fun toString(): String {
return "Add at position: $position"
}
}

View file

@ -0,0 +1,4 @@
package com.omgodse.notally.changehistory
abstract class ListBooleanChange(newValue: Boolean, position: Int, positionAfter: Int = position) :
ListValueChange<Boolean>(newValue, !newValue, position, positionAfter)

View file

@ -1,3 +1,3 @@
package com.philkes.notallyx.utils.changehistory
package com.omgodse.notally.changehistory
abstract class ListChange(internal val position: Int) : Change

View file

@ -0,0 +1,19 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
class ListCheckedChange(
checked: Boolean,
position: Int,
positionAfter: Int,
private val listManager: ListManager,
) : ListBooleanChange(checked, position, positionAfter) {
override fun update(position: Int, value: Boolean, isUndo: Boolean) {
listManager.changeChecked(position, value, pushChange = false)
}
override fun toString(): String {
return "CheckedChange pos: $position positionAfter: $positionAfter isChecked: $newValue"
}
}

View file

@ -0,0 +1,22 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
import com.omgodse.notally.room.ListItem
class ListDeleteChange(
position: Int,
internal val deletedItem: ListItem,
private val listManager: ListManager,
) : ListChange(position) {
override fun redo() {
listManager.delete(position, pushChange = false)
}
override fun undo() {
listManager.add(position, deletedItem, pushChange = false)
}
override fun toString(): String {
return "DeleteChange at $position"
}
}

View file

@ -0,0 +1,29 @@
package com.omgodse.notally.changehistory
import android.text.TextWatcher
import android.widget.EditText
import com.omgodse.notally.recyclerview.ListManager
open class ListEditTextChange(
private val editText: EditText,
position: Int,
private val textBefore: String,
private val textAfter: String,
private val listener: TextWatcher,
private val listManager: ListManager,
) : ListValueChange<String>(textAfter, textBefore, position) {
private val cursorPosition = editText.selectionStart
override fun update(position: Int, value: String, isUndo: Boolean) {
listManager.changeText(editText, listener, position, textBefore, value, pushChange = false)
editText.removeTextChangedListener(listener)
editText.setText(value)
editText.requestFocus()
editText.setSelection(Math.max(0, cursorPosition - (if (isUndo) 1 else 0)))
editText.addTextChangedListener(listener)
}
override fun toString(): String {
return "CheckedText at $position from: $textBefore to: $textAfter"
}
}

View file

@ -0,0 +1,14 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
class ListIsChildChange(isChild: Boolean, position: Int, private val listManager: ListManager) :
ListBooleanChange(isChild, position) {
override fun update(position: Int, value: Boolean, isUndo: Boolean) {
listManager.changeIsChild(position, value, pushChange = false)
}
override fun toString(): String {
return "IsChildChange position: $position isChild: $newValue"
}
}

View file

@ -0,0 +1,24 @@
package com.omgodse.notally.changehistory
import com.omgodse.notally.recyclerview.ListManager
import com.omgodse.notally.room.ListItem
class ListMoveChange(
positionFrom: Int,
internal val positionTo: Int,
internal var positionAfter: Int,
internal val itemBeforeMove: ListItem,
internal val listManager: ListManager,
) : ListChange(positionFrom) {
override fun redo() {
positionAfter = listManager.move(position, positionTo, pushChange = false)!!
}
override fun undo() {
listManager.revertMove(positionAfter, position, itemBeforeMove)
}
override fun toString(): String {
return "MoveChange from: $position to: $positionTo after: $positionAfter itemBeforeMove: $itemBeforeMove"
}
}

View file

@ -0,0 +1,19 @@
package com.omgodse.notally.changehistory
abstract class ListValueChange<T>(
internal val newValue: T,
internal val oldValue: T,
position: Int,
internal val positionAfter: Int = position,
) : ListChange(position) {
override fun redo() {
update(position, newValue, false)
}
override fun undo() {
update(positionAfter, oldValue, true)
}
abstract fun update(position: Int, value: T, isUndo: Boolean)
}

View file

@ -0,0 +1,14 @@
package com.omgodse.notally.changehistory
abstract class ValueChange<T>(protected val newValue: T, protected val oldValue: T) : Change {
override fun redo() {
update(newValue, false)
}
override fun undo() {
update(newValue, true)
}
abstract fun update(value: T, isUndo: Boolean)
}

View file

@ -0,0 +1,10 @@
package com.omgodse.notally.fragments
import com.omgodse.notally.R
class Archived : NotallyFragment() {
override fun getBackground() = R.drawable.archive
override fun getObservable() = model.archivedNotes
}

View file

@ -0,0 +1,26 @@
package com.omgodse.notally.fragments
import android.view.Menu
import android.view.MenuInflater
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.omgodse.notally.R
import com.omgodse.notally.miscellaneous.add
class Deleted : NotallyFragment() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(R.string.delete_all, R.drawable.delete_all) { deleteAllNotes() }
}
private fun deleteAllNotes() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.delete_all_notes)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteAllBaseNotes() }
.setNegativeButton(R.string.cancel, null)
.show()
}
override fun getBackground() = R.drawable.delete
override fun getObservable() = model.deletedNotes
}

View file

@ -0,0 +1,16 @@
package com.omgodse.notally.fragments
import androidx.lifecycle.LiveData
import com.omgodse.notally.R
import com.omgodse.notally.miscellaneous.Constants
import com.omgodse.notally.room.Item
class DisplayLabel : NotallyFragment() {
override fun getBackground() = R.drawable.label
override fun getObservable(): LiveData<List<Item>> {
val label = requireNotNull(requireArguments().getString(Constants.SelectedLabel))
return model.getNotesByLabel(label)
}
}

View file

@ -0,0 +1,158 @@
package com.omgodse.notally.fragments
import android.os.Bundle
import android.view.LayoutInflater
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.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.omgodse.notally.MenuDialog
import com.omgodse.notally.R
import com.omgodse.notally.databinding.DialogInputBinding
import com.omgodse.notally.databinding.FragmentNotesBinding
import com.omgodse.notally.miscellaneous.Constants
import com.omgodse.notally.miscellaneous.add
import com.omgodse.notally.recyclerview.ItemListener
import com.omgodse.notally.recyclerview.adapter.LabelAdapter
import com.omgodse.notally.room.Label
import com.omgodse.notally.viewmodels.BaseNoteModel
class Labels : Fragment(), ItemListener {
private var adapter: LabelAdapter? = null
private var binding: FragmentNotesBinding? = null
private val model: BaseNoteModel by activityViewModels()
override fun onDestroyView() {
super.onDestroyView()
binding = null
adapter = null
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = LabelAdapter(this)
binding?.RecyclerView?.setHasFixedSize(true)
binding?.RecyclerView?.adapter = adapter
binding?.RecyclerView?.layoutManager = LinearLayoutManager(requireContext())
val itemDecoration = DividerItemDecoration(requireContext(), RecyclerView.VERTICAL)
binding?.RecyclerView?.addItemDecoration(itemDecoration)
binding?.RecyclerView?.setPadding(0, 0, 0, 0)
binding?.ImageView?.setImageResource(R.drawable.label)
setupObserver()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
setHasOptionsMenu(true)
binding = FragmentNotesBinding.inflate(inflater)
return binding?.root
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(R.string.add_label, R.drawable.add) { displayAddLabelDialog() }
}
override fun onClick(position: Int) {
adapter?.currentList?.get(position)?.let { value ->
val bundle = Bundle()
bundle.putString(Constants.SelectedLabel, value)
findNavController().navigate(R.id.LabelsToDisplayLabel, bundle)
}
}
override fun onLongClick(position: Int) {
adapter?.currentList?.get(position)?.let { value ->
MenuDialog(requireContext())
.add(R.string.edit) { displayEditLabelDialog(value) }
.add(R.string.delete) { confirmDeletion(value) }
.show()
}
}
private fun setupObserver() {
model.labels.observe(viewLifecycleOwner) { labels ->
adapter?.submitList(labels)
binding?.ImageView?.isVisible = labels.isEmpty()
}
}
private fun displayAddLabelDialog() {
val inflater = LayoutInflater.from(requireContext())
val dialogBinding = DialogInputBinding.inflate(inflater)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.add_label)
.setView(dialogBinding.root)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.save) { dialog, _ ->
val value = dialogBinding.EditText.text.toString().trim()
if (value.isNotEmpty()) {
val label = Label(value)
model.insertLabel(label) { success: Boolean ->
if (success) {
dialog.dismiss()
} else
Toast.makeText(context, R.string.label_exists, Toast.LENGTH_LONG).show()
}
}
}
.show()
dialogBinding.EditText.requestFocus()
}
private fun confirmDeletion(value: String) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete_label)
.setMessage(R.string.your_notes_associated)
.setPositiveButton(R.string.delete) { _, _ -> model.deleteLabel(value) }
.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

@ -0,0 +1,158 @@
package com.omgodse.notally.fragments
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 androidx.lifecycle.LiveData
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.omgodse.notally.activities.MakeList
import com.omgodse.notally.activities.TakeNote
import com.omgodse.notally.databinding.FragmentNotesBinding
import com.omgodse.notally.miscellaneous.Constants
import com.omgodse.notally.preferences.View as ViewPref
import com.omgodse.notally.recyclerview.ItemListener
import com.omgodse.notally.recyclerview.adapter.BaseNoteAdapter
import com.omgodse.notally.room.BaseNote
import com.omgodse.notally.room.Item
import com.omgodse.notally.room.Type
import com.omgodse.notally.viewmodels.BaseNoteModel
abstract class NotallyFragment : Fragment(), ItemListener {
private var adapter: BaseNoteAdapter? = null
internal var binding: FragmentNotesBinding? = null
internal val model: BaseNoteModel by activityViewModels()
override fun onDestroyView() {
super.onDestroyView()
binding = null
adapter = null
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding?.ImageView?.setImageResource(getBackground())
setupAdapter()
setupRecyclerView()
setupObserver()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
setHasOptionsMenu(true)
binding = FragmentNotesBinding.inflate(inflater)
return binding?.root
}
// See [RecyclerView.ViewHolder.getAdapterPosition]
override fun onClick(position: Int) {
if (position != -1) {
adapter?.currentList?.get(position)?.let { item ->
if (item is BaseNote) {
if (model.actionMode.isEnabled()) {
handleNoteSelection(item.id, position, item)
} else {
when (item.type) {
Type.NOTE -> goToActivity(TakeNote::class.java, item)
Type.LIST -> goToActivity(MakeList::class.java, item)
}
}
}
}
}
}
override fun onLongClick(position: Int) {
if (position != -1) {
adapter?.currentList?.get(position)?.let { item ->
if (item is BaseNote) {
handleNoteSelection(item.id, position, item)
}
}
}
}
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)
adapter?.notifyItemChanged(position, 0)
}
private fun setupAdapter() {
val textSize = model.preferences.textSize.value
val maxItems = model.preferences.maxItems
val maxLines = model.preferences.maxLines
val maxTitle = model.preferences.maxTitle
val dateFormat = model.preferences.dateFormat.value
adapter =
BaseNoteAdapter(
model.actionMode.selectedIds,
dateFormat,
textSize,
maxItems,
maxLines,
maxTitle,
model.mediaRoot,
this,
)
adapter?.registerAdapterDataObserver(
object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (itemCount > 0) {
binding?.RecyclerView?.scrollToPosition(positionStart)
}
}
}
)
binding?.RecyclerView?.adapter = adapter
binding?.RecyclerView?.setHasFixedSize(true)
}
private fun setupObserver() {
getObservable().observe(viewLifecycleOwner) { list ->
adapter?.submitList(list)
binding?.ImageView?.isVisible = list.isEmpty()
}
model.actionMode.closeListener.observe(viewLifecycleOwner) { event ->
event.handle { ids ->
adapter?.currentList?.forEachIndexed { index, item ->
if (item is BaseNote && ids.contains(item.id)) {
adapter?.notifyItemChanged(index, 0)
}
}
}
}
}
private fun setupRecyclerView() {
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(Constants.SelectedBaseNote, baseNote.id)
startActivity(intent)
}
abstract fun getBackground(): Int
abstract fun getObservable(): LiveData<List<Item>>
}

View file

@ -0,0 +1,20 @@
package com.omgodse.notally.fragments
import android.view.Menu
import android.view.MenuInflater
import androidx.navigation.fragment.findNavController
import com.omgodse.notally.R
import com.omgodse.notally.miscellaneous.add
class Notes : NotallyFragment() {
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
override fun getBackground() = R.drawable.notebook
}

View file

@ -0,0 +1,39 @@
package com.omgodse.notally.fragments
import android.os.Build
import android.os.Bundle
import android.view.View
import com.omgodse.notally.R
import com.omgodse.notally.room.Folder
class Search : NotallyFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding?.ChipGroup?.visibility = View.VISIBLE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding?.RecyclerView?.scrollIndicators = View.SCROLL_INDICATOR_TOP
}
super.onViewCreated(view, savedInstanceState)
val checked =
when (model.folder) {
Folder.NOTES -> R.id.Notes
Folder.DELETED -> R.id.Deleted
Folder.ARCHIVED -> R.id.Archived
}
binding?.ChipGroup?.check(checked)
binding?.ChipGroup?.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
}
}
}
override fun getBackground() = R.drawable.search
override fun getObservable() = model.searchResults
}

View file

@ -0,0 +1,282 @@
package com.omgodse.notally.fragments
import android.app.Activity
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.FileProvider
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.omgodse.notally.BackupProgress
import com.omgodse.notally.MenuDialog
import com.omgodse.notally.R
import com.omgodse.notally.databinding.DialogProgressBinding
import com.omgodse.notally.databinding.FragmentSettingsBinding
import com.omgodse.notally.databinding.PreferenceBinding
import com.omgodse.notally.databinding.PreferenceSeekbarBinding
import com.omgodse.notally.miscellaneous.Operations
import com.omgodse.notally.preferences.*
import com.omgodse.notally.viewmodels.BaseNoteModel
class Settings : Fragment() {
private val model: BaseNoteModel by activityViewModels()
private fun setupBinding(binding: FragmentSettingsBinding) {
model.preferences.view.observe(viewLifecycleOwner) { value ->
binding.View.setup(View, value)
}
model.preferences.theme.observe(viewLifecycleOwner) { value ->
binding.Theme.setup(Theme, value)
}
model.preferences.dateFormat.observe(viewLifecycleOwner) { value ->
binding.DateFormat.setup(DateFormat, value)
}
model.preferences.textSize.observe(viewLifecycleOwner) { value ->
binding.TextSize.setup(TextSize, value)
}
model.preferences.listItemSorting.observe(viewLifecycleOwner) { value ->
binding.CheckedListItemSorting.setup(ListItemSorting, value)
}
binding.MaxItems.setup(MaxItems, model.preferences.maxItems)
binding.MaxLines.setup(MaxLines, model.preferences.maxLines)
binding.MaxTitle.setup(MaxTitle, model.preferences.maxTitle)
model.preferences.autoBackup.observe(viewLifecycleOwner) { value ->
binding.AutoBackup.setup(AutoBackup, 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/OmGodse/Notally") }
binding.Libraries.setOnClickListener { displayLibraries() }
binding.Rate.setOnClickListener {
openLink("https://play.google.com/store/apps/details?id=com.omgodse.notally")
}
binding.SendFeedback.setOnClickListener { sendEmailWithLog() }
}
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)
}
}
}
}
private fun exportBackup() {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.type = "application/zip"
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.putExtra(Intent.EXTRA_TITLE, "Notally Backup")
startActivityForResult(intent, REQUEST_EXPORT_BACKUP)
}
private fun importBackup() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.type = "*/*"
intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/zip", "text/xml"))
intent.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.ProgressBar.isIndeterminate = true
dialogBinding.Count.setText(R.string.calculating)
} else {
dialogBinding.ProgressBar.max = progress.total
dialogBinding.ProgressBar.setProgressCompat(progress.current, true)
dialogBinding.Count.text =
getString(R.string.count, progress.current, progress.total)
}
dialog.show()
} else dialog.dismiss()
}
}
private fun sendEmailWithLog() {
val intent = Intent(Intent.ACTION_SEND)
intent.selector = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("omgodseapps@gmail.com"))
intent.putExtra(Intent.EXTRA_SUBJECT, "Notally [Feedback]")
val app = requireContext().applicationContext as Application
val log = Operations.getLog(app)
if (log.exists()) {
val uri = FileProvider.getUriForFile(app, "${app.packageName}.provider", log)
intent.putExtra(Intent.EXTRA_STREAM, uri)
}
try {
startActivity(intent)
} catch (exception: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.install_an_email, Toast.LENGTH_LONG).show()
}
}
private fun displayLibraries() {
val libraries =
arrayOf(
"Glide",
"Pretty Time",
"Swipe Layout",
"Work Manager",
"Subsampling Scale ImageView",
"Material Components for Android",
)
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/rambler-digital-solutions/swipe-layout-android"
)
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"
)
}
}
.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: 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.valueTo = info.max.toFloat()
Slider.valueFrom = info.min.toFloat()
Slider.value = initialValue.toFloat()
Slider.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
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.misc
package com.omgodse.notally.image
import android.content.Context
import android.util.AttributeSet

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.utils
package com.omgodse.notally.image
class Event<T>(val data: T) {

View file

@ -0,0 +1,3 @@
package com.omgodse.notally.image
class ImageError(val name: String, val description: String)

View file

@ -0,0 +1,3 @@
package com.omgodse.notally.image
class ImageProgress(val inProgress: Boolean, val current: Int, val total: Int)

View file

@ -0,0 +1,62 @@
package com.omgodse.notally.legacy
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import com.omgodse.notally.room.BaseNote
import com.omgodse.notally.room.Folder
import com.omgodse.notally.room.Label
import java.io.File
// Backwards compatibility from v3.2 to v3.3
object Migrations {
fun clearAllLabels(app: Application) {
val preferences = getLabelsPreferences(app)
preferences.edit().clear().commit()
}
fun clearAllFolders(app: Application) {
getNotePath(app).listFiles()?.forEach { file -> file.delete() }
getDeletedPath(app).listFiles()?.forEach { file -> file.delete() }
getArchivedPath(app).listFiles()?.forEach { file -> file.delete() }
}
fun getPreviousLabels(app: Application): List<Label> {
val preferences = getLabelsPreferences(app)
val labels = requireNotNull(preferences.getStringSet("labelItems", emptySet()))
return labels.map { value -> Label(value) }
}
fun getPreviousNotes(app: Application): List<BaseNote> {
val list = ArrayList<BaseNote>()
getNotePath(app).listFiles()?.mapTo(list) { file ->
XMLUtils.readBaseNoteFromFile(file, Folder.NOTES)
}
getDeletedPath(app).listFiles()?.mapTo(list) { file ->
XMLUtils.readBaseNoteFromFile(file, Folder.DELETED)
}
getArchivedPath(app).listFiles()?.mapTo(list) { file ->
XMLUtils.readBaseNoteFromFile(file, Folder.ARCHIVED)
}
return list
}
private fun getNotePath(app: Application) = getFolder(app, "notes")
private fun getDeletedPath(app: Application) = getFolder(app, "deleted")
private fun getArchivedPath(app: Application) = getFolder(app, "archived")
private fun getLabelsPreferences(app: Application): SharedPreferences {
return app.getSharedPreferences("labelsPreferences", Context.MODE_PRIVATE)
}
private fun getFolder(app: Application, name: String): File {
val folder = File(app.filesDir, name)
if (!folder.exists()) {
folder.mkdir()
}
return folder
}
}

View file

@ -0,0 +1,153 @@
package com.omgodse.notally.legacy
import com.omgodse.notally.room.BaseNote
import com.omgodse.notally.room.Color
import com.omgodse.notally.room.Folder
import com.omgodse.notally.room.Label
import com.omgodse.notally.room.ListItem
import com.omgodse.notally.room.SpanRepresentation
import com.omgodse.notally.room.Type
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
object XMLUtils {
fun readBaseNoteFromFile(file: File, folder: Folder): BaseNote {
val inputStream = FileInputStream(file)
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(inputStream, null)
parser.next()
return parseBaseNote(parser, parser.name, folder)
}
fun readBackupFromStream(inputStream: InputStream): Pair<List<BaseNote>, List<Label>> {
val parser = XmlPullParserFactory.newInstance().newPullParser()
parser.setInput(inputStream, null)
val baseNotes = ArrayList<BaseNote>()
val labels = ArrayList<Label>()
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"notes" -> parseList(parser, parser.name, baseNotes, Folder.NOTES)
"deleted-notes" -> parseList(parser, parser.name, baseNotes, Folder.DELETED)
"archived-notes" -> parseList(parser, parser.name, baseNotes, Folder.ARCHIVED)
"label" -> labels.add(Label(parser.nextText()))
}
}
}
return Pair(baseNotes, labels)
}
private fun parseList(
parser: XmlPullParser,
rootTag: String,
list: ArrayList<BaseNote>,
folder: Folder,
) {
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
val note = parseBaseNote(parser, parser.name, folder)
list.add(note)
} else if (parser.eventType == XmlPullParser.END_TAG) {
if (parser.name == rootTag) {
break
}
}
}
}
private fun parseBaseNote(parser: XmlPullParser, rootTag: String, folder: Folder): BaseNote {
var color = Color.DEFAULT
var body = String()
var title = String()
var timestamp = 0L
var pinned = false
val items = ArrayList<ListItem>()
val labels = ArrayList<String>()
val spans = ArrayList<SpanRepresentation>()
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"color" -> color = Color.valueOf(parser.nextText())
"title" -> title = parser.nextText()
"body" -> body = parser.nextText()
"date-created" -> timestamp = parser.nextText().toLong()
"pinned" -> pinned = parser.nextText().toBoolean()
"label" -> labels.add(parser.nextText())
"item" -> items.add(parseItem(parser, parser.name))
"span" -> spans.add(parseSpan(parser))
}
} else if (parser.eventType == XmlPullParser.END_TAG) {
if (parser.name == rootTag) {
break
}
}
}
// Can be either `note` or `list`
val type =
if (rootTag == "note") {
Type.NOTE
} else Type.LIST
return BaseNote(
0,
type,
folder,
color,
title,
pinned,
timestamp,
labels,
body,
spans,
items,
emptyList(),
emptyList(),
)
}
private fun parseItem(parser: XmlPullParser, rootTag: String): ListItem {
var body = String()
var checked = false
var isChild = false
var sortingPosition: Int? = null
// TODO: migration required?
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"text" -> body = parser.nextText()
"checked" -> checked = parser.nextText()?.toBoolean() ?: false
"isChild" -> isChild = parser.nextText()?.toBoolean() ?: false
"sortingPosition" -> sortingPosition = parser.nextText()?.toInt()
}
} else if (parser.eventType == XmlPullParser.END_TAG) {
if (parser.name == rootTag) {
break
}
}
}
return ListItem(body, checked, isChild, sortingPosition, mutableListOf())
}
private fun parseSpan(parser: XmlPullParser): SpanRepresentation {
val start = parser.getAttributeValue(null, "start").toInt()
val end = parser.getAttributeValue(null, "end").toInt()
val bold = parser.getAttributeValue(null, "bold")?.toBoolean() ?: false
val link = parser.getAttributeValue(null, "link")?.toBoolean() ?: false
val italic = parser.getAttributeValue(null, "italic")?.toBoolean() ?: false
val monospace = parser.getAttributeValue(null, "monospace")?.toBoolean() ?: false
val strikethrough = parser.getAttributeValue(null, "strike")?.toBoolean() ?: false
return SpanRepresentation(bold, link, italic, monospace, strikethrough, start, end)
}
}

View file

@ -0,0 +1,59 @@
package com.omgodse.notally.miscellaneous
import com.omgodse.notally.room.ListItem
class CheckedSorter : ListItemSorterStrategy {
override fun sort(
list: MutableList<ListItem>,
initSortingPositions: Boolean,
): MutableList<ListItem> {
if (initSortingPositions) {
list.forEachIndexed { index, item ->
if (item.sortingPosition == null) item.sortingPosition = index
}
}
// Sorted by parents
val sortedGroups =
list
.mapIndexedNotNull { idx, item ->
if (item.isChild) {
null
} else if (idx < list.lastIndex) {
val itemsBelow = list.subList(idx + 1, list.size)
var nextParentIdx = itemsBelow.indexOfFirst { !it.isChild }
if (nextParentIdx == -1) {
// there is only children below it
nextParentIdx = list.lastIndex
} else {
nextParentIdx += idx
}
val items = list.subList(idx, nextParentIdx + 1)
if (items.size > 1) {
items[0].children =
list.subList(idx + 1, nextParentIdx + 1).toMutableList()
}
items
} else {
mutableListOf(item)
}
}
.sortedWith(
Comparator { i1, i2 ->
val parent1 = i1[0]
val parent2 = i2[0]
if (parent1.checked && !parent2.checked) {
return@Comparator 1
}
if (!parent1.checked && parent2.checked) {
return@Comparator -1
}
return@Comparator parent1.sortingPosition!!.compareTo(
parent2.sortingPosition!!
)
}
)
val sortedItems = sortedGroups.flatten().toMutableList()
// sortedItems.updateSortingPositions()
return sortedItems
}
}

View file

@ -0,0 +1,6 @@
package com.omgodse.notally.miscellaneous
object Constants {
const val SelectedLabel = "SelectedLabel"
const val SelectedBaseNote = "SelectedBaseNote"
}

View file

@ -0,0 +1,37 @@
package com.omgodse.notally.miscellaneous
import android.app.Application
import com.omgodse.notally.room.NotallyDatabase
import java.io.File
import java.io.FileInputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object Export {
fun backupDatabase(app: Application, zipStream: ZipOutputStream) {
val entry = ZipEntry(NotallyDatabase.DatabaseName)
zipStream.putNextEntry(entry)
val file = app.getDatabasePath(NotallyDatabase.DatabaseName)
val inputStream = FileInputStream(file)
inputStream.copyTo(zipStream)
inputStream.close()
zipStream.closeEntry()
}
fun backupFile(zipStream: ZipOutputStream, root: File?, folder: String, name: String) {
val file = if (root != null) File(root, name) else null
if (file != null && file.exists()) {
val entry = ZipEntry("$folder/$name")
zipStream.putNextEntry(entry)
val inputStream = FileInputStream(file)
inputStream.copyTo(zipStream)
inputStream.close()
zipStream.closeEntry()
}
}
}

View file

@ -0,0 +1,205 @@
package com.omgodse.notally.miscellaneous
import android.content.res.Resources
import android.graphics.Typeface
import android.text.Editable
import android.text.InputType
import android.text.Spannable
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.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.RemoteViews
import android.widget.TextView
import com.omgodse.notally.activities.TakeNote
import com.omgodse.notally.changehistory.ChangeHistory
import com.omgodse.notally.changehistory.EditTextChange
import com.omgodse.notally.recyclerview.ListManager
import com.omgodse.notally.room.SpanRepresentation
import java.util.Date
import kotlin.math.roundToInt
import org.ocpsoft.prettytime.PrettyTime
/**
* For some reason, this method crashes sometimes with an IndexOutOfBoundsException that I've not
* been able to replicate. When this happens, to prevent the entire app from crashing and becoming
* unusable, the exception is suppressed.
*/
fun String.applySpans(representations: List<SpanRepresentation>): Editable {
val editable = Editable.Factory.getInstance().newEditable(this)
representations.forEach { (bold, link, italic, monospace, strikethrough, start, end) ->
try {
if (bold) {
editable.setSpan(StyleSpan(Typeface.BOLD), start, end)
}
if (italic) {
editable.setSpan(StyleSpan(Typeface.ITALIC), start, end)
}
if (link) {
val url = getURL(start, end)
editable.setSpan(URLSpan(url), start, end)
}
if (monospace) {
editable.setSpan(TypefaceSpan("monospace"), start, end)
}
if (strikethrough) {
editable.setSpan(StrikethroughSpan(), start, end)
}
} catch (exception: Exception) {
exception.printStackTrace()
}
}
return editable
}
private fun String.getURL(start: Int, end: Int): String {
return if (end <= length) {
TakeNote.getURLFrom(substring(start, end))
} else TakeNote.getURLFrom(substring(start, length))
}
private fun Spannable.setSpan(span: Any, start: Int, end: Int) {
if (end <= length) {
setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else setSpan(span, start, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
fun EditText.setOnNextAction(onNext: () -> Unit) {
setRawInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
setOnKeyListener { v, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
onNext()
return@setOnKeyListener true
} else return@setOnKeyListener false
}
setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
onNext()
return@setOnEditorActionListener true
} else return@setOnEditorActionListener false
}
}
fun Menu.add(title: Int, drawable: Int, onClick: (item: MenuItem) -> Unit): MenuItem {
return add(Menu.NONE, title, drawable, MenuItem.SHOW_AS_ACTION_IF_ROOM, onClick)
}
fun Menu.add(
title: Int,
drawable: Int,
showAsAction: Int,
onClick: (item: MenuItem) -> Unit,
): MenuItem {
return add(Menu.NONE, title, drawable, showAsAction, onClick)
}
fun Menu.add(
groupId: Int,
title: Int,
drawable: Int,
showAsAction: Int,
onClick: (item: MenuItem) -> Unit,
): MenuItem {
val menuItem = add(groupId, Menu.NONE, Menu.NONE, title)
menuItem.setIcon(drawable)
menuItem.setOnMenuItemClickListener { item ->
onClick(item)
return@setOnMenuItemClickListener false
}
menuItem.setShowAsAction(showAsAction)
return menuItem
}
fun TextView.displayFormattedTimestamp(timestamp: Long, dateFormat: String) {
if (dateFormat != com.omgodse.notally.preferences.DateFormat.none) {
visibility = View.VISIBLE
text = formatTimestamp(timestamp, dateFormat)
} else visibility = View.GONE
}
fun RemoteViews.displayFormattedTimestamp(id: Int, timestamp: Long, dateFormat: String) {
if (dateFormat != com.omgodse.notally.preferences.DateFormat.none) {
setViewVisibility(id, View.VISIBLE)
setTextViewText(id, formatTimestamp(timestamp, dateFormat))
} else setViewVisibility(id, View.GONE)
}
val Int.dp: Int
get() = (this / Resources.getSystem().displayMetrics.density).roundToInt()
/**
* Creates a TextWatcher for an EditText that is part of a list. Everytime the text is changed, a
* Change is added to the ChangeHistory.
*
* @param positionGetter Function to determine the current position of the EditText in the list
* (e.g. the current adapterPosition when using RecyclerViewer.Adapter)
* @param updateModel Function to update the model. Is called on any text changes and on undo/redo.
*/
fun EditText.createListTextWatcherWithHistory(listManager: ListManager, positionGetter: () -> Int) =
object : TextWatcher {
private lateinit var currentTextBefore: String
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
currentTextBefore = s.toString()
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
listManager.changeText(
this@createListTextWatcherWithHistory,
this,
positionGetter.invoke(),
currentTextBefore,
requireNotNull(s).toString(),
)
}
}
fun EditText.createTextWatcherWithHistory(
changeHistory: ChangeHistory,
updateModel: (text: String) -> Unit,
) =
object : TextWatcher {
private lateinit var currentTextBefore: String
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
currentTextBefore = s.toString()
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val textBefore = currentTextBefore
val textAfter = requireNotNull(s).toString()
updateModel.invoke(textAfter)
changeHistory.push(
EditTextChange(
this@createTextWatcherWithHistory,
textAfter,
textBefore,
this,
updateModel,
)
)
}
}
private fun formatTimestamp(timestamp: Long, dateFormat: String): String {
val date = Date(timestamp)
return when (dateFormat) {
com.omgodse.notally.preferences.DateFormat.relative -> PrettyTime().format(date)
else -> java.text.DateFormat.getDateInstance(java.text.DateFormat.FULL).format(date)
}
}

View file

@ -0,0 +1,66 @@
package com.omgodse.notally.miscellaneous
import android.app.Application
import android.content.Context
import android.os.Build
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.nio.file.Files
object IO {
private fun createDirectory(file: File) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.createDirectory(file.toPath())
} else file.mkdir()
}
fun getExternalImagesDirectory(app: Application) = getExternalDirectory(app, "Images")
fun getExternalAudioDirectory(app: Application) = getExternalDirectory(app, "Audios")
private fun getExternalDirectory(app: Application, name: String): File? {
var file: File? = null
try {
val mediaDir = app.externalMediaDirs.firstOrNull()
if (mediaDir != null) {
file = File(mediaDir, name)
if (file.exists()) {
if (!file.isDirectory) {
file.delete()
createDirectory(file)
}
} else createDirectory(file)
}
} catch (exception: Exception) {
exception.printStackTrace()
}
return file
}
fun getTempAudioFile(context: Context): File {
return File(context.externalCacheDir, "Temp.m4a")
}
fun copyStreamToFile(input: InputStream, destination: File) {
val output = FileOutputStream(destination)
input.copyTo(output)
input.close()
output.close()
}
fun renameFile(file: File, name: String): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val source = file.toPath()
val destination = source.resolveSibling(name)
Files.move(source, destination)
true // If move failed, an exception would have been thrown
} else {
val destination = file.resolveSibling(name)
file.renameTo(destination)
}
}
}

View file

@ -0,0 +1,11 @@
package com.omgodse.notally.miscellaneous
import com.omgodse.notally.room.ListItem
interface ListItemSorterStrategy {
fun sort(
list: MutableList<ListItem>,
initSortingPosition: Boolean = false,
): MutableList<ListItem>
}

View file

@ -0,0 +1,134 @@
package com.omgodse.notally.miscellaneous
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Build
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import androidx.core.content.ContextCompat
import com.google.android.material.chip.ChipGroup
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.RelativeCornerSize
import com.google.android.material.shape.RoundedCornerTreatment
import com.google.android.material.shape.ShapeAppearanceModel
import com.omgodse.notally.BuildConfig
import com.omgodse.notally.R
import com.omgodse.notally.databinding.LabelBinding
import com.omgodse.notally.preferences.TextSize
import com.omgodse.notally.room.Color
import com.omgodse.notally.room.ListItem
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.text.DateFormat
object Operations {
const val extraCharSequence = "com.omgodse.notally.extra.charSequence"
fun getLog(app: Application): File {
val folder = File(app.filesDir, "logs")
folder.mkdir()
return File(folder, "Log.v1.txt")
}
fun log(app: Application, throwable: Throwable) {
val file = getLog(app)
val output = FileOutputStream(file, true)
val writer = PrintWriter(OutputStreamWriter(output, Charsets.UTF_8))
val formatter = DateFormat.getDateTimeInstance()
val time = formatter.format(System.currentTimeMillis())
writer.println("[Start]")
throwable.printStackTrace(writer)
writer.println("Version code : " + BuildConfig.VERSION_CODE)
writer.println("Version name : " + BuildConfig.VERSION_NAME)
writer.println("Model : " + Build.MODEL)
writer.println("Device : " + Build.DEVICE)
writer.println("Brand : " + Build.BRAND)
writer.println("Manufacturer : " + Build.MANUFACTURER)
writer.println("Android : " + Build.VERSION.SDK_INT)
writer.println("Time : $time")
writer.println("[End]")
writer.close()
}
fun extractColor(color: Color, context: Context): Int {
val id =
when (color) {
Color.DEFAULT -> R.color.Default
Color.CORAL -> R.color.Coral
Color.ORANGE -> R.color.Orange
Color.SAND -> R.color.Sand
Color.STORM -> R.color.Storm
Color.FOG -> R.color.Fog
Color.SAGE -> R.color.Sage
Color.MINT -> R.color.Mint
Color.DUSK -> R.color.Dusk
Color.FLOWER -> R.color.Flower
Color.BLOSSOM -> R.color.Blossom
Color.CLAY -> R.color.Clay
}
return ContextCompat.getColor(context, id)
}
fun shareNote(context: Context, title: String, body: CharSequence) {
val text = body.toString()
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(extraCharSequence, body)
intent.putExtra(Intent.EXTRA_TEXT, text)
intent.putExtra(Intent.EXTRA_TITLE, title)
intent.putExtra(Intent.EXTRA_SUBJECT, title)
val chooser = Intent.createChooser(intent, null)
context.startActivity(chooser)
}
fun getBody(list: List<ListItem>) = buildString {
for (item in list) {
val check = if (item.checked) "[✓]" else "[ ]"
val childIndentation = if (item.isChild) " " else ""
appendLine("$childIndentation$check ${item.body}")
}
}
fun bindLabels(group: ChipGroup, labels: List<String>, textSize: String) {
if (labels.isEmpty()) {
group.visibility = View.GONE
} else {
group.visibility = View.VISIBLE
group.removeAllViews()
val inflater = LayoutInflater.from(group.context)
val labelSize = TextSize.getDisplayBodySize(textSize)
for (label in labels) {
val view = LabelBinding.inflate(inflater, group, true).root
view.background = getOutlinedDrawable(group.context)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, labelSize)
view.text = label
}
}
}
private fun getOutlinedDrawable(context: Context): MaterialShapeDrawable {
val model =
ShapeAppearanceModel.builder()
.setAllCorners(RoundedCornerTreatment())
.setAllCornerSizes(RelativeCornerSize(0.5f))
.build()
val drawable = MaterialShapeDrawable(model)
drawable.fillColor = ColorStateList.valueOf(0)
drawable.strokeWidth = context.resources.displayMetrics.density
drawable.strokeColor = ContextCompat.getColorStateList(context, R.color.chip_stroke)
return drawable
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
package com.philkes.notallyx.presentation.view.misc
package com.omgodse.notally.preferences
import androidx.lifecycle.MutableLiveData
// LiveData that doesn't accept null values
open class NotNullLiveData<T>(value: T) : MutableLiveData<T>(value) {
class BetterLiveData<T>(value: T) : MutableLiveData<T>(value) {
override fun getValue(): T {
return requireNotNull(super.getValue())

View file

@ -0,0 +1,148 @@
package com.omgodse.notally.preferences
import android.content.Context
import com.omgodse.notally.R
import java.text.DateFormat
import java.util.Date
import org.ocpsoft.prettytime.PrettyTime
sealed interface ListInfo {
val title: Int
val key: String
val defaultValue: String
fun getEntryValues(): Array<String>
fun getEntries(context: Context): Array<String>
fun convertToValues(ids: Array<Int>, context: Context): Array<String> {
return Array(ids.size) { index ->
val id = ids[index]
context.getString(id)
}
}
}
object View : ListInfo {
const val list = "list"
const val grid = "grid"
override val title = R.string.view
override val key = "view"
override val defaultValue = list
override fun getEntryValues() = arrayOf(list, grid)
override fun getEntries(context: Context): Array<String> {
val ids = arrayOf(R.string.list, R.string.grid)
return convertToValues(ids, context)
}
}
object Theme : ListInfo {
const val dark = "dark"
const val light = "light"
const val followSystem = "followSystem"
override val title = R.string.theme
override val key = "theme"
override val defaultValue = followSystem
override fun getEntryValues() = arrayOf(dark, light, followSystem)
override fun getEntries(context: Context): Array<String> {
val ids = arrayOf(R.string.dark, R.string.light, R.string.follow_system)
return convertToValues(ids, context)
}
}
object DateFormat : ListInfo {
const val none = "none"
const val relative = "relative"
const val absolute = "absolute"
override val title = R.string.date_format
override val key = "dateFormat"
override val defaultValue = relative
override fun getEntryValues() = arrayOf(none, relative, absolute)
override fun getEntries(context: Context): Array<String> {
val none = context.getString(R.string.none)
val date = Date(System.currentTimeMillis() - 86400000)
val relative = PrettyTime().format(date)
val absolute = DateFormat.getDateInstance(DateFormat.FULL).format(date)
return arrayOf(none, relative, absolute)
}
}
object TextSize : ListInfo {
const val small = "small"
const val medium = "medium"
const val large = "large"
override val title = R.string.text_size
override val key = "textSize"
override val defaultValue = medium
override fun getEntryValues() = arrayOf(small, medium, large)
override fun getEntries(context: Context): Array<String> {
val ids = arrayOf(R.string.small, R.string.medium, R.string.large)
return convertToValues(ids, context)
}
fun getEditBodySize(textSize: String): Float {
return when (textSize) {
small -> 14f
medium -> 16f
large -> 18f
else -> throw IllegalArgumentException("Invalid : $textSize")
}
}
fun getEditTitleSize(textSize: String): Float {
return when (textSize) {
small -> 18f
medium -> 20f
large -> 22f
else -> throw IllegalArgumentException("Invalid : $textSize")
}
}
fun getDisplayBodySize(textSize: String): Float {
return when (textSize) {
small -> 12f
medium -> 14f
large -> 16f
else -> throw IllegalArgumentException("Invalid : $textSize")
}
}
fun getDisplayTitleSize(textSize: String): Float {
return when (textSize) {
small -> 14f
medium -> 16f
large -> 18f
else -> throw IllegalArgumentException("Invalid : $textSize")
}
}
}
object ListItemSorting : ListInfo {
const val noAutoSort = "noAutoSort"
const val autoSortByChecked = "autoSortByChecked"
override val title = R.string.checked_list_item_sorting
override val key = "checkedListItemSorting"
override val defaultValue = noAutoSort
override fun getEntryValues() = arrayOf(noAutoSort, autoSortByChecked)
override fun getEntries(context: Context): Array<String> {
val ids = arrayOf(R.string.no_auto_sort, R.string.auto_sort_by_checked)
return convertToValues(ids, context)
}
}

View file

@ -0,0 +1,118 @@
package com.omgodse.notally.preferences
import android.app.Application
import android.preference.PreferenceManager
/**
* 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 textSize = BetterLiveData(getListPref(TextSize))
val listItemSorting = BetterLiveData(getListPref(ListItemSorting))
var maxItems = getSeekbarPref(MaxItems)
var maxLines = getSeekbarPref(MaxLines)
var maxTitle = getSeekbarPref(MaxTitle)
val autoBackup = BetterLiveData(getTextPref(AutoBackup))
private fun getListPref(info: ListInfo) =
requireNotNull(preferences.getString(info.key, info.defaultValue))
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)
}
}
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))
}
}
fun savePreference(info: TextInfo, value: String) {
editor.putString(info.key, value)
editor.commit()
when (info) {
AutoBackup -> autoBackup.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)
this.instance = instance
return instance
}
}
}
}

View file

@ -0,0 +1,47 @@
package com.omgodse.notally.preferences
import com.omgodse.notally.R
sealed interface SeekbarInfo {
val title: Int
val key: String
val defaultValue: Int
val min: Int
val max: Int
}
object MaxItems : SeekbarInfo {
override val title = R.string.max_items_to_display
override val key = "maxItemsToDisplayInList.v1"
override val defaultValue = 4
override val min = 1
override val max = 10
}
object MaxLines : SeekbarInfo {
override val title = R.string.max_lines_to_display
override val key = "maxLinesToDisplayInNote.v1"
override val defaultValue = 8
override val min = 1
override val max = 10
}
object MaxTitle : SeekbarInfo {
override val title = R.string.max_lines_to_display_title
override val key = "maxLinesToDisplayInTitle"
override val defaultValue = 1
override val min = 1
override val max = 10
}

View file

@ -0,0 +1,20 @@
package com.omgodse.notally.preferences
import com.omgodse.notally.R
sealed interface TextInfo {
val title: Int
val key: String
val defaultValue: String
}
object AutoBackup : TextInfo {
const val emptyPath = "emptyPath"
override val title = R.string.auto_backup
override val key = "autoBackup"
override val defaultValue = emptyPath
}

View file

@ -1,26 +1,23 @@
package com.philkes.notallyx.presentation.view.note.listitem
package com.omgodse.notally.recyclerview
import android.graphics.Canvas
import android.util.Log
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.philkes.notallyx.data.model.ListItem
import com.omgodse.notally.room.ListItem
/** ItemTouchHelper.Callback that allows dragging ListItem with its children. */
class ListItemDragCallback(private val elevation: Float, internal val listManager: ListManager) :
class DragCallback(private val elevation: Float, private val listManager: ListManager) :
ItemTouchHelper.Callback() {
private var lastState = ItemTouchHelper.ACTION_STATE_IDLE
private var lastIsCurrentlyActive = false
private var childViewHolders: List<ViewHolder> = mutableListOf()
private var stateBefore: ListState? = null
private var draggedItem: ListItem? = null
private var positionFrom: Int? = null
private var parentBefore: ListItem? = null
private var itemCount: Int? = null
private var positionTo: Int? = null
private var newPosition: Int? = null
override fun isLongPressDragEnabled() = false
@ -33,31 +30,28 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
}
override fun onMove(view: RecyclerView, viewHolder: ViewHolder, target: ViewHolder): Boolean {
val from = viewHolder.absoluteAdapterPosition
val to = target.absoluteAdapterPosition
if (from == -1 || to == -1) {
return false
}
return move(from, to)
}
internal fun move(from: Int, to: Int): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition
if (positionFrom == null) {
positionFrom = from
stateBefore = listManager.getState(selectedPos = from)
val item = listManager.getItem(from)
parentBefore = if (item.isChild) listManager.findParent(item)?.second else null
draggedItem = listManager.getItem(from).clone() as ListItem
}
val (positionTo, itemCount) = listManager.move(from, to)
if (positionTo != -1) {
this.itemCount = itemCount
this.positionTo = positionTo
val swapped = listManager.move(from, to, false, false)
if (swapped != null) {
if (positionFrom == null) {
positionFrom = from
}
positionTo = to
newPosition = swapped
}
return positionTo != -1
return swapped != null
}
override fun onSelectedChanged(viewHolder: ViewHolder?, actionState: Int) {
if (lastState != actionState && actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
if (
lastState != actionState &&
actionState == ItemTouchHelper.ACTION_STATE_IDLE &&
positionTo != -1
) {
onDragEnd()
}
lastState = actionState
@ -87,52 +81,49 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
}
override fun clearView(recyclerView: RecyclerView, viewHolder: ViewHolder) {
viewHolder.itemView.apply {
translationX = 0f
translationY = 0f
elevation = 0f
}
viewHolder.itemView.translationX = 0f
viewHolder.itemView.translationY = 0f
viewHolder.itemView.elevation = 0f
childViewHolders.forEach { animateFadeIn(it) }
}
private fun onDragStart(viewHolder: ViewHolder, recyclerView: RecyclerView) {
reset()
if (viewHolder.absoluteAdapterPosition == -1) {
return
}
val item = listManager.getItem(viewHolder.absoluteAdapterPosition)
Log.d(TAG, "onDragStart")
positionFrom = null
positionTo = null
newPosition = null
draggedItem = null
val item = listManager.getItem(viewHolder.adapterPosition)
if (!item.isChild) {
childViewHolders =
item.children.mapIndexedNotNull { index, _ ->
item.children.mapIndexedNotNull { index, listItem ->
recyclerView.findViewHolderForAdapterPosition(
viewHolder.absoluteAdapterPosition + index + 1
viewHolder.adapterPosition + index + 1
)
}
childViewHolders.forEach { animateFadeOut(it) }
}
}
internal fun onDragEnd() {
private fun onDragEnd() {
Log.d(TAG, "onDragEnd: from: $positionFrom to: $positionTo")
if (positionTo != null && positionTo != -1 && stateBefore != null) {
if (positionFrom == positionTo) {
return
}
if (newPosition != null && draggedItem != null) {
// The items have already been moved accordingly via move() calls
listManager.finishMove(
positionFrom!!,
positionTo!!,
itemCount!!,
parentBefore,
stateBefore!!,
pushChange = true,
newPosition!!,
draggedItem!!,
true,
true,
)
}
}
internal fun reset() {
positionFrom = null
positionTo = null
newPosition = null
stateBefore = null
}
private fun animateFadeOut(viewHolder: ViewHolder) {
viewHolder.itemView.animate().translationY(-100f).alpha(0f).setDuration(300).start()
}
@ -142,6 +133,6 @@ class ListItemDragCallback(private val elevation: Float, internal val listManage
}
companion object {
private const val TAG = "ListItemDragCallback"
private const val TAG = "DragCallback"
}
}

View file

@ -1,4 +1,4 @@
package com.philkes.notallyx.presentation.view.misc
package com.omgodse.notally.recyclerview
interface ItemListener {

View file

@ -0,0 +1,19 @@
package com.omgodse.notally.recyclerview
import androidx.recyclerview.widget.DiffUtil
import com.omgodse.notally.room.ListItem
class ListItemCallback(private val oldList: List<ListItem>, private val newList: List<ListItem>) :
DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldPosition: Int, newPosition: Int): Boolean {
return oldList[oldPosition].id == newList[newPosition].id
}
override fun areContentsTheSame(oldPosition: Int, newPosition: Int): Boolean {
return oldList[oldPosition] == newList[newPosition]
}
}

View file

@ -0,0 +1,259 @@
package com.omgodse.notally.recyclerview
import androidx.recyclerview.widget.SortedList
import com.omgodse.notally.room.ListItem
fun ListItemSortedList.addItem(position: Int, item: ListItem) {
item.sortingPosition = position
this.add(item)
}
fun ListItemSortedList.moveItemRange(
fromIndex: Int,
itemCount: Int,
toIndex: Int,
forceIsChild: Boolean? = null,
): Int? {
if (fromIndex == toIndex || itemCount <= 0) return null
this.beginBatchedUpdates()
val insertPosition = if (fromIndex < toIndex) toIndex - itemCount + 1 else toIndex
if (fromIndex < toIndex) {
this.addToSortingPositions(fromIndex + itemCount until toIndex + 1, -itemCount)
} else {
this.addToSortingPositions(toIndex until fromIndex, itemCount)
}
val itemsToMove = (0 until itemCount).map { this.removeItemAt(fromIndex) }
itemsToMove.forEachIndexed { index, item ->
val movedItem = item.clone() as ListItem
movedItem.sortingPosition = insertPosition + index
this.add(movedItem, forceIsChild)
}
this.endBatchedUpdates()
val newPosition = this.indexOfFirst { it.id == itemsToMove[0].id }!!
return newPosition
}
// fun ListItemSortedList.addAndNotify(
// position: Int,
// item: ListItem,
// adapter: RecyclerView.Adapter<*>,
// ) {
// if (item.checked && item.sortingPosition == null) {
// item.sortingPosition = position
// }
// add(position, item)
// adapter.notifyItemInserted(position)
// }
fun ListItemSortedList.deleteItem(
position: Int,
childrenToDelete: List<ListItem>? = null,
): ListItem {
this.beginBatchedUpdates()
val item = this[position]
val deletedItem = this[position].clone() as ListItem
val children = childrenToDelete ?: item.children
// for (i in position + children.size until this.size()) {
// this[i].sortingPosition = this[i].sortingPosition!! - (children.size + 1)
// }
this.addToSortingPositions(position + children.size until this.size(), -(children.size + 1))
(item + children).indices.forEach { this.removeItemAt(position) }
this.endBatchedUpdates()
return deletedItem
}
fun ListItemSortedList.addToSortingPositions(positionRange: IntRange, valueToAdd: Int) {
this.forEach {
if (it.sortingPosition!! in positionRange) {
it.sortingPosition = it.sortingPosition!! + valueToAdd
}
}
}
fun <T, R> SortedList<T>.map(transform: (T) -> R): List<R> {
return (0 until this.size()).map { transform.invoke(this[it]) }
}
fun <T> SortedList<T>.forEach(function: (item: T) -> Unit) {
return (0 until this.size()).forEach { function.invoke(this[it]) }
}
fun <T> SortedList<T>.forEachIndexed(function: (idx: Int, item: T) -> Unit) {
for (i in 0 until this.size()) {
function.invoke(i, this[i])
}
}
fun <T> SortedList<T>.filter(function: (item: T) -> Boolean): List<T> {
val list = mutableListOf<T>()
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
list.add(this[i])
}
}
return list.toList()
}
fun <T> SortedList<T>.find(function: (item: T) -> Boolean): T? {
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
return this[i]
}
}
return null
}
fun <T> SortedList<T>.indexOfFirst(function: (item: T) -> Boolean): Int? {
for (i in 0 until this.size()) {
if (function.invoke(this[i])) {
return i
}
}
return null
}
val <T> SortedList<T>.lastIndex: Int
get() = this.size() - 1
val <T> SortedList<T>.indices: IntRange
get() = (0 until this.size())
fun <T> SortedList<T>.isNotEmpty(): Boolean {
return size() > 0
}
fun <T> SortedList<T>.isEmpty(): Boolean {
return size() == 0
}
fun ListItemSortedList.toMutableList(): MutableList<ListItem> {
return this.indices.map { this[it] }.toMutableList()
}
fun ListItemSortedList.cloneList(): MutableList<ListItem> {
return this.indices.map { this[it].clone() as ListItem }.toMutableList()
}
fun ListItemSortedList.setIsChild(
position: Int,
isChild: Boolean,
forceOnChildren: Boolean = false,
) {
if (forceOnChildren) {
this.setIsChild((position..position + this[position].children.size).toList(), isChild)
} else {
val item = this[position].clone() as ListItem
if (item.isChild != isChild) {
item.isChild = isChild
this.updateItemAt(position, item)
// this.updateAllChildren()
}
}
}
fun ListItemSortedList.setIsChild(positions: List<Int>, isChild: Boolean) {
val changedPositions = mutableListOf<Int>()
val items = this.cloneList()
positions.forEach {
val item = items[it]
if (item.isChild != isChild) {
changedPositions.add(it)
item.isChild = isChild
}
}
updatePositions(changedPositions, items)
}
fun ListItemSortedList.setChecked(position: Int, checked: Boolean): Int {
val item = this[position].clone() as ListItem
if (item.checked != checked) {
item.checked = checked
this.updateItemAt(position, item)
return this.indexOf(item)
}
return position
}
fun ListItemSortedList.setChecked(
positions: Collection<Int>,
checked: Boolean,
): Pair<List<Int>, List<Int>> {
val changedPositions = mutableListOf<Int>()
val items = this.cloneList()
positions.forEach {
val item = items[it]
if (item.checked != checked) {
changedPositions.add(it)
item.checked = checked
}
}
val changedPositionsAfterSort = updatePositions(changedPositions, items)
return Pair(changedPositions, changedPositionsAfterSort)
}
/**
* Checks item at position and its children
*
* @return The position of the checked item afterwards
*/
fun ListItemSortedList.setCheckedWithChildren(position: Int, checked: Boolean): Int {
val parent = this[position]
var childrenWithSameChecked = 0
parent.children.forEach {
val updatedChild = this.find { item -> item.id == it.id }!!
if (updatedChild.checked != checked) {
childrenWithSameChecked++
}
}
val positionsWithChildren = (position..position + childrenWithSameChecked).toList()
val (_, changedPositionsAfterSort) = this.setChecked(positionsWithChildren, checked)
return changedPositionsAfterSort[0]
}
operator fun ListItem.plus(list: List<ListItem>): List<ListItem> {
return mutableListOf(this) + list
}
fun ListItemSortedList.toReadableString(): String {
return map { "${it.toString()} sortingPosition: ${it.sortingPosition} id: ${it.id}" }
.joinToString("\n")
}
fun Collection<ListItem>.toReadableString(): String {
return map { "${it.toString()} uncheckedPos: ${it.sortingPosition} id: ${it.id}" }
.joinToString("\n")
}
fun ListItemSortedList.findParent(childItem: ListItem): Pair<Int, ListItem>? {
this.indices.forEach {
if (this[it].children.find { child -> child.id == childItem.id } != null) {
return Pair(it, this[it])
}
}
return null
}
private fun ListItemSortedList.updatePositions(
changedPositions: MutableList<Int>,
items: MutableList<ListItem>,
): List<Int> {
this.beginBatchedUpdates()
changedPositions.forEach {
val updatedItem = items[it]
val newPosition = this.indexOfFirst { item -> item.id == updatedItem.id }!!
this.updateItemAt(newPosition, updatedItem)
}
val changedPositionsAfterSort =
changedPositions
.map { pos -> this.indexOfFirst { item -> item.id == items[pos].id }!! }
.toList()
this.endBatchedUpdates()
return changedPositionsAfterSort
}

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