├── app
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ ├── colors.xml
│ │ │ ├── attrs.xml
│ │ │ ├── dimens.xml
│ │ │ └── styles.xml
│ │ ├── mipmap-hdpi
│ │ │ └── ic_launcher.png
│ │ ├── mipmap-mdpi
│ │ │ └── ic_launcher.png
│ │ ├── mipmap-xhdpi
│ │ │ └── ic_launcher.png
│ │ ├── mipmap-xxhdpi
│ │ │ └── ic_launcher.png
│ │ ├── mipmap-xxxhdpi
│ │ │ └── ic_launcher.png
│ │ ├── drawable-hdpi
│ │ │ ├── ic_add_black_24dp.png
│ │ │ ├── ic_add_white_24dp.png
│ │ │ ├── ic_clear_black_24dp.png
│ │ │ ├── ic_clear_white_24dp.png
│ │ │ ├── ic_done_black_24dp.png
│ │ │ ├── ic_create_black_24dp.png
│ │ │ ├── ic_delete_black_24dp.png
│ │ │ ├── ic_history_black_24dp.png
│ │ │ ├── ic_history_white_24dp.png
│ │ │ ├── ic_search_black_24dp.png
│ │ │ ├── ic_search_white_24dp.png
│ │ │ ├── ic_check_box_black_24dp.png
│ │ │ ├── ic_visibility_white_24dp.png
│ │ │ └── ic_check_box_outline_blank_black_24dp.png
│ │ ├── drawable-mdpi
│ │ │ ├── ic_add_black_24dp.png
│ │ │ ├── ic_add_white_24dp.png
│ │ │ ├── ic_clear_black_24dp.png
│ │ │ ├── ic_clear_white_24dp.png
│ │ │ ├── ic_done_black_24dp.png
│ │ │ ├── ic_create_black_24dp.png
│ │ │ ├── ic_delete_black_24dp.png
│ │ │ ├── ic_history_black_24dp.png
│ │ │ ├── ic_history_white_24dp.png
│ │ │ ├── ic_search_black_24dp.png
│ │ │ ├── ic_search_white_24dp.png
│ │ │ ├── ic_check_box_black_24dp.png
│ │ │ ├── ic_visibility_white_24dp.png
│ │ │ └── ic_check_box_outline_blank_black_24dp.png
│ │ ├── drawable-xhdpi
│ │ │ ├── ic_add_black_24dp.png
│ │ │ ├── ic_add_white_24dp.png
│ │ │ ├── ic_done_black_24dp.png
│ │ │ ├── ic_clear_black_24dp.png
│ │ │ ├── ic_clear_white_24dp.png
│ │ │ ├── ic_create_black_24dp.png
│ │ │ ├── ic_delete_black_24dp.png
│ │ │ ├── ic_search_black_24dp.png
│ │ │ ├── ic_search_white_24dp.png
│ │ │ ├── ic_check_box_black_24dp.png
│ │ │ ├── ic_history_black_24dp.png
│ │ │ ├── ic_history_white_24dp.png
│ │ │ ├── ic_visibility_white_24dp.png
│ │ │ └── ic_check_box_outline_blank_black_24dp.png
│ │ ├── drawable-xxhdpi
│ │ │ ├── ic_add_black_24dp.png
│ │ │ ├── ic_add_white_24dp.png
│ │ │ ├── ic_clear_black_24dp.png
│ │ │ ├── ic_clear_white_24dp.png
│ │ │ ├── ic_done_black_24dp.png
│ │ │ ├── ic_create_black_24dp.png
│ │ │ ├── ic_delete_black_24dp.png
│ │ │ ├── ic_history_black_24dp.png
│ │ │ ├── ic_history_white_24dp.png
│ │ │ ├── ic_search_black_24dp.png
│ │ │ ├── ic_search_white_24dp.png
│ │ │ ├── ic_check_box_black_24dp.png
│ │ │ ├── ic_visibility_white_24dp.png
│ │ │ └── ic_check_box_outline_blank_black_24dp.png
│ │ ├── drawable-xxxhdpi
│ │ │ ├── ic_add_black_24dp.png
│ │ │ ├── ic_add_white_24dp.png
│ │ │ ├── ic_done_black_24dp.png
│ │ │ ├── ic_clear_black_24dp.png
│ │ │ ├── ic_clear_white_24dp.png
│ │ │ ├── ic_create_black_24dp.png
│ │ │ ├── ic_delete_black_24dp.png
│ │ │ ├── ic_history_black_24dp.png
│ │ │ ├── ic_history_white_24dp.png
│ │ │ ├── ic_search_black_24dp.png
│ │ │ ├── ic_search_white_24dp.png
│ │ │ ├── ic_check_box_black_24dp.png
│ │ │ ├── ic_visibility_white_24dp.png
│ │ │ └── ic_check_box_outline_blank_black_24dp.png
│ │ ├── values-w820dp
│ │ │ └── dimens.xml
│ │ ├── drawable
│ │ │ ├── book_selector.xml
│ │ │ ├── baseline_change_history_24.xml
│ │ │ ├── ic_baseline_save_alt_24.xml
│ │ │ ├── baseline_done_24.xml
│ │ │ ├── baseline_delete_sweep_24.xml
│ │ │ ├── baseline_launch_24.xml
│ │ │ ├── baseline_shuffle_24.xml
│ │ │ ├── baseline_favorite_24.xml
│ │ │ ├── baseline_edit_24.xml
│ │ │ └── ic_baseline_settings_backup_restore_24.xml
│ │ ├── xml
│ │ │ └── network_security_config.xml
│ │ ├── layout
│ │ │ ├── audit_audit_event_resource_name_popup.xml
│ │ │ ├── index_query.xml
│ │ │ ├── page_fragment.xml
│ │ │ ├── index_book_title_popup.xml
│ │ │ ├── library.xml
│ │ │ ├── audit_activity_audit_events.xml
│ │ │ ├── database_management_fragment.xml
│ │ │ ├── sidebar_item.xml
│ │ │ ├── activity_reading.xml
│ │ │ ├── library_edit_fragment.xml
│ │ │ ├── audit_audit_event.xml
│ │ │ ├── index_book_view.xml
│ │ │ ├── libraries_fragment.xml
│ │ │ └── activity_index.xml
│ │ └── menu
│ │ │ └── list_menu.xml
│ │ ├── ic_launcher-web.png
│ │ ├── java
│ │ └── net
│ │ │ └── bloople
│ │ │ └── manga
│ │ │ ├── SearchResults.kt
│ │ │ ├── audit
│ │ │ ├── ResourceType.kt
│ │ │ ├── Action.kt
│ │ │ ├── LibrariesAuditor.kt
│ │ │ ├── AuditEventsViewModel.kt
│ │ │ ├── BooksAuditor.kt
│ │ │ ├── AuditEventsSearcher.kt
│ │ │ ├── AuditEventsActivity.kt
│ │ │ ├── AuditEvent.kt
│ │ │ ├── DatabaseHelper.kt
│ │ │ └── AuditEventsAdapter.kt
│ │ │ ├── MangaApplication.kt
│ │ │ ├── MangaGlideModule.kt
│ │ │ ├── DatabaseExtensions.kt
│ │ │ ├── ThrottledOnClickListener.kt
│ │ │ ├── InflatePagePaths.kt
│ │ │ ├── Book.kt
│ │ │ ├── TagChooserFragment.kt
│ │ │ ├── MatchWidthTransformation.kt
│ │ │ ├── QueryAdapter.kt
│ │ │ ├── TouchRejectionFrameLayout.kt
│ │ │ ├── PageFragment.kt
│ │ │ ├── QueryService.kt
│ │ │ ├── IndexViewModel.kt
│ │ │ ├── ReadingSession.kt
│ │ │ ├── BooksSearcher.kt
│ │ │ ├── BookList.kt
│ │ │ ├── Query.kt
│ │ │ ├── BookPagerAdapter.kt
│ │ │ ├── LibrariesAdapter.kt
│ │ │ ├── MangosUrl.kt
│ │ │ ├── LibraryService.kt
│ │ │ ├── ReadingActivity.kt
│ │ │ ├── BookListAdapter.kt
│ │ │ ├── Library.kt
│ │ │ ├── BookMetadata.kt
│ │ │ ├── LibraryEditFragment.kt
│ │ │ ├── BooksSorter.kt
│ │ │ ├── StringNextUtil.kt
│ │ │ ├── CursorRecyclerAdapter.kt
│ │ │ ├── CollectionsManager.kt
│ │ │ ├── DatabaseManagementFragment.kt
│ │ │ ├── DatabaseHelper.kt
│ │ │ ├── LibrariesFragment.kt
│ │ │ ├── BooksAdapter.kt
│ │ │ └── IndexActivity.kt
│ │ └── AndroidManifest.xml
├── proguard-rules.pro
└── build.gradle
├── settings.gradle
├── .gitignore
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── fix_book_keys.rb
├── gradle.properties
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | /app/release
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Manga
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/ic_launcher-web.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_add_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_add_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_clear_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_clear_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_clear_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_done_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_done_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_add_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_add_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_clear_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_clear_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_clear_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_done_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_done_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_add_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_add_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_done_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_done_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_add_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_add_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #000000
4 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_create_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_create_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_delete_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_delete_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_history_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_history_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_history_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_history_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_search_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_search_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_create_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_create_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_delete_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_delete_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_history_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_history_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_history_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_history_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_search_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_search_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_clear_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_clear_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_clear_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_create_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_create_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_delete_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_delete_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_search_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_search_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_clear_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_clear_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_clear_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_done_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_done_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_add_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_add_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_add_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_add_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_done_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_done_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_check_box_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_check_box_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_visibility_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_visibility_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_check_box_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_check_box_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_visibility_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_visibility_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_check_box_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_check_box_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_history_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_history_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_history_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_history_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_create_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_create_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_delete_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_delete_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_history_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_history_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_history_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_history_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_search_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_search_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_clear_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_clear_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_clear_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_create_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_create_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_delete_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_delete_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_history_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_history_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_history_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_history_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_search_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_search_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_visibility_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_visibility_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_check_box_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_check_box_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_visibility_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_visibility_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_check_box_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_check_box_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_visibility_white_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_visibility_white_24dp.png
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/SearchResults.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | class SearchResults(val books: ArrayList, val booksMetadata: HashMap)
4 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/ResourceType.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | internal enum class ResourceType {
4 | UNKNOWN,
5 | LIBRARY,
6 | BOOK
7 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_check_box_outline_blank_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-hdpi/ic_check_box_outline_blank_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_check_box_outline_blank_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-mdpi/ic_check_box_outline_blank_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_check_box_outline_blank_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xhdpi/ic_check_box_outline_blank_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_check_box_outline_blank_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxhdpi/ic_check_box_outline_blank_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_check_box_outline_blank_black_24dp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bloopletech/Manga/master/app/src/main/res/drawable-xxxhdpi/ic_check_box_outline_blank_black_24dp.png
--------------------------------------------------------------------------------
/app/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16dp
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/Action.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | internal enum class Action {
4 | UNKNOWN,
5 | LIBRARY_CREATED,
6 | LIBRARY_UPDATED,
7 | LIBRARY_DESTROYED,
8 | LIBRARY_SELECTED,
9 | BOOK_METADATA_CREATED,
10 | BOOK_METADATA_UPDATED,
11 | BOOK_OPENED,
12 | BOOK_CLOSED
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-w820dp/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 64dp
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/book_selector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_change_history_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/MangaApplication.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.app.Application
4 | import coil3.ImageLoader
5 | import coil3.PlatformContext
6 | import coil3.SingletonImageLoader
7 |
8 | class MangaApplication : Application(), SingletonImageLoader.Factory {
9 | override fun newImageLoader(context: PlatformContext): ImageLoader {
10 | return ImageLoader.Builder(context)
11 | .build()
12 | }
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_save_alt_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_done_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/audit_audit_event_resource_name_popup.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_delete_sweep_24.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/index_query.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_launch_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_shuffle_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/page_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_favorite_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_edit_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/LibrariesAuditor.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.content.Context
4 | import net.bloople.manga.Library
5 |
6 | class LibrariesAuditor(private val context: Context) {
7 | fun selected(library: Library) {
8 | val event = AuditEvent(
9 | System.currentTimeMillis(),
10 | Action.LIBRARY_SELECTED,
11 | ResourceType.UNKNOWN,
12 | AuditEvent.UNKNOWN_ID,
13 | ResourceType.LIBRARY,
14 | library.id,
15 | library.name!!,
16 | ""
17 | )
18 | event.save(context)
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/MangaGlideModule.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import com.bumptech.glide.GlideBuilder
5 | import com.bumptech.glide.annotation.GlideModule
6 | import com.bumptech.glide.load.engine.DiskCacheStrategy
7 | import com.bumptech.glide.module.AppGlideModule
8 | import com.bumptech.glide.request.RequestOptions
9 |
10 |
11 | @GlideModule
12 | class MangaGlideModule : AppGlideModule() {
13 | override fun applyOptions(context: Context, builder: GlideBuilder) {
14 | builder.setDefaultRequestOptions(RequestOptions().diskCacheStrategy(DiskCacheStrategy.NONE))
15 | }
16 |
17 | override fun isManifestParsingEnabled(): Boolean {
18 | return false
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/fix_book_keys.rb:
--------------------------------------------------------------------------------
1 | require 'json'
2 | require 'pathname'
3 |
4 | gem 'sqlite3'
5 | require 'sqlite3'
6 |
7 | key_mapping = JSON.parse(Pathname.new("key_mapping.json").read)
8 |
9 | db = SQLite3::Database.new("Manga.db")
10 | db_audit = SQLite3::Database.new("MangaAudit.db")
11 |
12 | key_mapping.each_pair do |old_key, key|
13 | old_book_id = old_key[0..14].to_i(16)
14 | book_id = key[0..14].to_i(16)
15 |
16 | query = "UPDATE books_metadata SET book_id=? WHERE book_id=?"
17 | values = [book_id, old_book_id]
18 | db.execute(query, values)
19 |
20 | query = "UPDATE lists_books SET book_id=? WHERE book_id=?"
21 | values = [book_id, old_book_id]
22 | db.execute(query, values)
23 |
24 | query = "UPDATE audit_events SET resource_id=? WHERE resource_id=?"
25 | values = [book_id, old_book_id]
26 | db_audit.execute(query, values)
27 | end
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/DatabaseExtensions.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.database.Cursor
4 |
5 | internal inline operator fun Cursor.get(columnName: String): T {
6 | return when(T::class) {
7 | Double::class -> this.getDouble(this.getColumnIndexOrThrow(columnName)) as T
8 | Float::class -> this.getFloat(this.getColumnIndexOrThrow(columnName)) as T
9 | Int::class -> this.getInt(this.getColumnIndexOrThrow(columnName)) as T
10 | Long::class -> this.getLong(this.getColumnIndexOrThrow(columnName)) as T
11 | Short::class -> this.getShort(this.getColumnIndexOrThrow(columnName)) as T
12 | String::class, CharSequence::class -> this.getString(this.getColumnIndexOrThrow(columnName)) as T
13 | else -> throw IllegalArgumentException("Cursor does not have a getter for type ${T::class.qualifiedName}")
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/AuditEventsViewModel.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.app.Application
4 | import android.database.Cursor
5 | import androidx.lifecycle.AndroidViewModel
6 | import androidx.lifecycle.MutableLiveData
7 | import androidx.lifecycle.viewModelScope
8 | import kotlinx.coroutines.launch
9 |
10 | class AuditEventsViewModel(application: Application) : AndroidViewModel(application) {
11 | val searchResults: MutableLiveData by lazy {
12 | MutableLiveData()
13 | }
14 |
15 | private val searcher = AuditEventsSearcher()
16 |
17 | fun setResourceId(resourceId: Long?) {
18 | searcher.resourceId = resourceId
19 | resolve()
20 | }
21 |
22 | private fun resolve() {
23 | viewModelScope.launch {
24 | searchResults.postValue(searcher.search(getApplication()))
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | ## For more details on how to configure your build environment visit
2 | # http://www.gradle.org/docs/current/userguide/build_environment.html
3 | #
4 | # Specifies the JVM arguments used for the daemon process.
5 | # The setting is particularly useful for tweaking memory settings.
6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m
7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
8 | #
9 | # When configured, Gradle will run in incubating parallel mode.
10 | # This option should only be used with decoupled projects. For more details, visit
11 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
12 | # org.gradle.parallel=true
13 | #Fri Oct 11 23:28:25 AEDT 2024
14 | android.nonFinalResIds=true
15 | android.nonTransitiveRClass=true
16 | android.useAndroidX=true
17 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
18 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/index_book_title_popup.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
14 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/library.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
14 |
15 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/ThrottledOnClickListener.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.os.SystemClock
4 | import android.view.View
5 |
6 | internal abstract class ThrottledOnClickListener : View.OnClickListener {
7 | private var lastClickMillis: Long = 0
8 |
9 | override fun onClick(v: View) {
10 | val now = SystemClock.elapsedRealtime()
11 | if(now - lastClickMillis > THRESHOLD_MILLIS) onThrottledClick(v)
12 | lastClickMillis = now
13 | }
14 |
15 | abstract fun onThrottledClick(v: View?)
16 |
17 | companion object {
18 | const val THRESHOLD_MILLIS = 1000L
19 |
20 | @JvmStatic
21 | fun wrap(clickListener: View.OnClickListener): View.OnClickListener {
22 | return object : ThrottledOnClickListener() {
23 | override fun onThrottledClick(v: View?) {
24 | clickListener.onClick(v)
25 | }
26 | }
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/InflatePagePaths.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import java.util.ArrayList
4 |
5 | fun inflatePagePaths(pagesString: String): ArrayList {
6 | val paths = ArrayList()
7 | val parts = pagesString.split("|")
8 |
9 | for(part in parts) {
10 | if(part.contains("/")) {
11 | val partParts = part.split("/")
12 | val name = partParts[0]
13 | val count = Integer.valueOf(partParts[1])
14 | val lastPeriod = name.lastIndexOf(".")
15 | var lastBase = name.substring(0, lastPeriod)
16 | val lastExt = name.substring(lastPeriod)
17 | paths.add(name)
18 |
19 | for(i in 0 until count) {
20 | lastBase = StringNextUtil.next(lastBase)
21 | paths.add(lastBase + lastExt)
22 | }
23 | }
24 | else {
25 | paths.add(part)
26 | }
27 | }
28 |
29 | return paths
30 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/audit_activity_audit_events.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
17 |
18 |
19 |
24 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/database_management_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
16 |
17 |
23 |
24 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/BooksAuditor.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.content.Context
4 | import net.bloople.manga.Book
5 | import net.bloople.manga.Library
6 |
7 | class BooksAuditor(private val context: Context) {
8 | fun opened(library: Library, book: Book, page: Int) {
9 | val event = AuditEvent(
10 | System.currentTimeMillis(),
11 | Action.BOOK_OPENED,
12 | ResourceType.LIBRARY,
13 | library.id,
14 | ResourceType.BOOK,
15 | book.id,
16 | book.title,
17 | "Page $page"
18 | )
19 | event.save(context)
20 | }
21 |
22 | fun closed(library: Library, book: Book, page: Int) {
23 | val event = AuditEvent(
24 | System.currentTimeMillis(),
25 | Action.BOOK_CLOSED,
26 | ResourceType.LIBRARY,
27 | library.id,
28 | ResourceType.BOOK,
29 | book.id,
30 | book.title,
31 | "Page $page"
32 | )
33 | event.save(context)
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/sidebar_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
14 |
27 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/Book.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import kotlinx.serialization.SerialName
4 | import java.util.ArrayList
5 |
6 | import kotlinx.serialization.Serializable
7 | import kotlinx.serialization.Transient
8 |
9 | @Serializable
10 | class Book(
11 | val path: String,
12 | @SerialName("pagePaths")
13 | val pagePathsDeflated: String,
14 | val pages: Int,
15 | val publishedOn: Int,
16 | val key: String,
17 | val tags: List
18 | ) {
19 | @Transient
20 | lateinit var library: Library
21 | val title: String by lazy { path.replace("\\s+".toRegex(), " ") }
22 | val normalisedTitle: String by lazy { path.replace("[^A-Za-z0-9]+".toRegex(), "").lowercase() }
23 | val id: Long by lazy { key.substring(0, 15).toLong(16) } //Using substring of key would be dangerous for large N
24 | private val pagePaths: ArrayList by lazy { inflatePagePaths(pagePathsDeflated) }
25 | val thumbnailUrl: MangosUrl by lazy { library.thumbnailsUrl / "$key.jpg" }
26 |
27 | fun pageUrl(index: Int): MangosUrl {
28 | return library.rootUrl / path / pagePaths[index]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/TagChooserFragment.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.app.AlertDialog
4 | import android.app.Dialog
5 | import android.os.Bundle
6 | import android.content.DialogInterface
7 | import androidx.fragment.app.DialogFragment
8 |
9 | class TagChooserFragment : DialogFragment() {
10 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
11 | val arguments = arguments
12 | val tags = arguments!!.getStringArray("tags")
13 |
14 | val builder = AlertDialog.Builder(activity)
15 | builder.setTitle("Search for tag")
16 | .setItems(tags) { _: DialogInterface?, which: Int ->
17 | val activity = activity as IndexActivity?
18 | activity!!.useTag(tags!![which])
19 | }
20 | return builder.create()
21 | }
22 |
23 | companion object {
24 | fun newInstance(tags: Array): TagChooserFragment {
25 | val fragment = TagChooserFragment()
26 | val args = Bundle()
27 | args.putStringArray("tags", tags)
28 | fragment.arguments = args
29 | return fragment
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/res/menu/list_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/AuditEventsSearcher.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.content.Context
4 | import android.database.Cursor
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.withContext
7 |
8 | class AuditEventsSearcher internal constructor() {
9 | var resourceId: Long? = null
10 |
11 | suspend fun search(context: Context): Cursor {
12 | val cursor: Cursor
13 |
14 | withContext(Dispatchers.IO) {
15 | val db = DatabaseHelper.instance(context)
16 |
17 | cursor = resourceId?.let {
18 | db.query(
19 | "audit_events",
20 | null,
21 | "resource_id = ?", arrayOf(it.toString()),
22 | null,
23 | null,
24 | "\"when\" DESC"
25 | )
26 | } ?: db.query(
27 | "audit_events",
28 | null,
29 | null,
30 | null,
31 | null,
32 | null,
33 | "\"when\" DESC"
34 | )
35 |
36 | cursor.moveToFirst()
37 | }
38 |
39 | return cursor
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/MatchWidthTransformation.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
4 | import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
5 | import android.graphics.Bitmap
6 | import com.bumptech.glide.load.resource.bitmap.TransformationUtils
7 | import java.nio.charset.StandardCharsets
8 | import java.security.MessageDigest
9 |
10 | class MatchWidthTransformation : BitmapTransformation() {
11 | public override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
12 | val targetHeight = toTransform.height
13 | return TransformationUtils.fitCenter(pool, toTransform, outWidth, targetHeight)
14 | }
15 |
16 | override fun equals(other: Any?): Boolean {
17 | return other is MatchWidthTransformation
18 | }
19 |
20 | override fun hashCode(): Int {
21 | return ID.hashCode()
22 | }
23 |
24 | override fun updateDiskCacheKey(messageDigest: MessageDigest) {
25 | messageDigest.update(ID_BYTES)
26 | }
27 |
28 | companion object {
29 | private const val ID = "net.bloople.manga.MatchWidthTransformation"
30 | private val ID_BYTES = ID.toByteArray(StandardCharsets.UTF_8)
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/QueryAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import android.database.Cursor
5 | import android.view.ViewGroup
6 | import android.view.LayoutInflater
7 | import android.view.View
8 | import android.widget.CursorAdapter
9 | import android.widget.TextView
10 |
11 | class QueryAdapter internal constructor(context: Context, cursor: Cursor?) : CursorAdapter(context, cursor, 0) {
12 | // The newView method is used to inflate a new view and return it,
13 | // you don't bind any data to the view at this point.
14 | override fun newView(context: Context, cursor: Cursor, parent: ViewGroup): View {
15 | return LayoutInflater.from(context).inflate(R.layout.index_query, parent, false)
16 | }
17 |
18 | // The bindView method is used to bind all data to a given view
19 | // such as setting the text on a TextView.
20 | override fun bindView(view: View, context: Context, cursor: Cursor) {
21 | // Find fields to populate in inflated template
22 | val textView = view as TextView
23 | textView.text = cursor["text"]
24 | }
25 |
26 | override fun convertToString(cursor: Cursor): String {
27 | //returns string inserted into textview after item from drop-down list is selected.
28 | return cursor["text"]
29 | }
30 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/TouchRejectionFrameLayout.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import android.widget.FrameLayout
5 | import android.util.AttributeSet
6 | import android.view.MotionEvent
7 |
8 | class TouchRejectionFrameLayout @JvmOverloads constructor(
9 | context: Context,
10 | attrs: AttributeSet?,
11 | defStyleAttr: Int = 0,
12 | defStyleRes: Int = 0
13 | ) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
14 | private val touchRejectionOffset: Int
15 |
16 | init {
17 | val a = context.theme.obtainStyledAttributes(attrs, R.styleable.TouchRejectionFrameLayout, 0, 0)
18 |
19 | touchRejectionOffset = try {
20 | a.getDimensionPixelSize(
21 | R.styleable.TouchRejectionFrameLayout_touchRejectionOffset,
22 | 0
23 | )
24 | }
25 | finally {
26 | a.recycle()
27 | }
28 | }
29 |
30 | override fun onTouchEvent(ev: MotionEvent): Boolean {
31 | return shouldRejectEvent(ev)
32 | }
33 |
34 | override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
35 | return shouldRejectEvent(ev)
36 | }
37 |
38 | private fun shouldRejectEvent(ev: MotionEvent): Boolean {
39 | return ev.y < touchRejectionOffset || ev.y > height - touchRejectionOffset
40 | }
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/PageFragment.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import android.widget.ImageView
8 | import androidx.fragment.app.Fragment
9 | import coil3.load
10 |
11 | class PageFragment : Fragment() {
12 | private lateinit var url: MangosUrl
13 |
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | super.onCreate(savedInstanceState)
16 | url = requireArguments().getParcelable("url")!!
17 | }
18 |
19 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
20 | return inflater.inflate(R.layout.page_fragment, container, false)
21 | }
22 |
23 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
24 | super.onViewCreated(view, savedInstanceState)
25 | val imageView: ImageView = view.findViewById(R.id.image)
26 |
27 | imageView.load(null) {
28 | url.loadInto(this)
29 | }
30 | }
31 |
32 | companion object {
33 | @JvmStatic
34 | fun newInstance(url: MangosUrl?): PageFragment {
35 | val fragment = PageFragment()
36 | val args = Bundle()
37 | args.putParcelable("url", url)
38 | fragment.arguments = args
39 | return fragment
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_reading.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
13 |
17 |
18 |
19 |
26 |
27 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/AuditEventsActivity.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import androidx.appcompat.app.AppCompatActivity
4 | import androidx.recyclerview.widget.RecyclerView
5 | import android.os.Bundle
6 | import net.bloople.manga.R
7 | import androidx.recyclerview.widget.LinearLayoutManager
8 | import android.database.Cursor
9 | import androidx.activity.enableEdgeToEdge
10 | import androidx.lifecycle.ViewModelProvider
11 |
12 | class AuditEventsActivity : AppCompatActivity() {
13 | private lateinit var model: AuditEventsViewModel
14 |
15 | private lateinit var auditEventsView: RecyclerView
16 | private lateinit var adapter: AuditEventsAdapter
17 |
18 | override fun onCreate(savedInstanceState: Bundle?) {
19 | enableEdgeToEdge()
20 | super.onCreate(savedInstanceState)
21 |
22 | setContentView(R.layout.audit_activity_audit_events)
23 |
24 | model = ViewModelProvider(this)[AuditEventsViewModel::class.java]
25 |
26 | auditEventsView = findViewById(R.id.audit_events)
27 | auditEventsView.layoutManager = LinearLayoutManager(this)
28 |
29 | adapter = AuditEventsAdapter(null)
30 | auditEventsView.adapter = adapter
31 |
32 | model.searchResults.observe(this) { searchResults: Cursor -> adapter.swapCursor(searchResults) }
33 |
34 | val resourceId = intent.getLongExtra("resourceId", -1)
35 |
36 | model.setResourceId(if(resourceId != -1L) resourceId else null)
37 | }
38 |
39 | override fun onBackPressed() {
40 | finish()
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/library_edit_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
18 |
26 |
34 |
42 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/QueryService.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import android.widget.AutoCompleteTextView
5 | import android.widget.FilterQueryProvider
6 |
7 | class QueryService(private val context: Context, searchField: AutoCompleteTextView) {
8 | private val adapter: QueryAdapter = QueryAdapter(context, null)
9 |
10 | init {
11 | adapter.filterQueryProvider = FilterQueryProvider { constraint: CharSequence? ->
12 | val db = DatabaseHelper.instance(context)
13 | if(constraint != null) {
14 | db.rawQuery(
15 | "SELECT _id, text FROM queries WHERE text LIKE ? OR text LIKE ? ORDER BY last_used_at DESC LIMIT $FILTER_QUERIES_LIMIT",
16 | arrayOf("$constraint%", "\"$constraint%")
17 | )
18 | }
19 | else {
20 | db.rawQuery(
21 | "SELECT _id, text FROM queries ORDER BY last_used_at DESC LIMIT $FILTER_QUERIES_LIMIT",
22 | emptyArray()
23 | )
24 | }
25 | }
26 |
27 | searchField.setAdapter(adapter)
28 | searchField.setOnDismissListener { adapter.cursor.close() }
29 | }
30 |
31 | fun onSearch(text: String) {
32 | val now = System.currentTimeMillis()
33 | var existing = Query.findByText(context, text)
34 | if(existing == null) {
35 | existing = Query()
36 | existing.text = text
37 | existing.createdAt = now
38 | }
39 | existing.lastUsedAt = now
40 | existing.usedCount++
41 | existing.save(context)
42 | }
43 |
44 | companion object {
45 | const val FILTER_QUERIES_LIMIT = 100
46 | }
47 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/audit_audit_event.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
19 |
20 |
25 |
26 |
27 |
34 |
35 |
44 |
45 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
20 |
21 |
25 |
26 |
29 |
30 |
32 |
33 |
37 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/IndexViewModel.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.app.Application
4 | import androidx.lifecycle.AndroidViewModel
5 | import androidx.lifecycle.MutableLiveData
6 | import androidx.lifecycle.viewModelScope
7 | import kotlinx.coroutines.launch
8 |
9 | class IndexViewModel(application: Application) : AndroidViewModel(application) {
10 | private var library: Library? = null
11 | private val searcher = BooksSearcher()
12 | private val sorter = BooksSorter()
13 |
14 | val searchResults: MutableLiveData by lazy {
15 | MutableLiveData()
16 | }
17 |
18 | val sorterDescription: MutableLiveData by lazy {
19 | MutableLiveData(sorter.description())
20 | }
21 |
22 | fun setLibrary(library: Library) {
23 | this.library = library
24 | resolve()
25 | }
26 |
27 | fun getLibrary(): Library? {
28 | return library
29 | }
30 |
31 | val sortMethod: BooksSortMethod
32 | get() = sorter.sortMethod
33 | val sortDirectionAsc: Boolean
34 | get() = sorter.sortDirectionAsc
35 |
36 | fun setSearchText(searchText: String) {
37 | searcher.searchText = searchText
38 | resolve()
39 | }
40 |
41 | fun setSort(sortMethod: BooksSortMethod, sortDirectionAsc: Boolean) {
42 | sorter.sortMethod = sortMethod
43 | sorter.sortDirectionAsc = sortDirectionAsc
44 | sorterDescription.value = sorter.description()
45 | resolve()
46 | }
47 |
48 | fun useList(list: BookList?) {
49 | searcher.filterIds = list?.bookIds(getApplication()) ?: ArrayList()
50 | resolve()
51 | }
52 |
53 | private fun resolve() {
54 | viewModelScope.launch {
55 | val books = searcher.search(library ?: return@launch)
56 | val booksMetadata = BookMetadata.findAllByBookIds(getApplication(), books)
57 | sorter.sort(books, booksMetadata)
58 | searchResults.postValue(SearchResults(books, booksMetadata))
59 | }
60 | }
61 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/ReadingSession.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import androidx.viewpager2.widget.ViewPager2
4 | import net.bloople.manga.audit.BooksAuditor
5 | import android.annotation.SuppressLint
6 | import android.content.Context
7 | import androidx.fragment.app.FragmentActivity
8 | import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
9 |
10 | internal class ReadingSession(private val context: Context, private val library: Library, private val book: Book) {
11 | private var pager: ViewPager2? = null
12 | private val metadata: BookMetadata = BookMetadata.findOrCreateByBookId(context, book.id)
13 | private val auditor: BooksAuditor = BooksAuditor(context)
14 |
15 | fun start() {
16 | metadata.lastOpenedAt = System.currentTimeMillis()
17 | metadata.openedCount++
18 | metadata.save(context)
19 | auditor.opened(library, book, page())
20 | }
21 |
22 | @SuppressLint("WrongConstant")
23 | fun bind(fa: FragmentActivity, pager: ViewPager2) {
24 | this.pager = pager
25 |
26 | pager.adapter = BookPagerAdapter(fa, book)
27 |
28 | pager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
29 | override fun onPageSelected(position: Int) {
30 | bookmark(position)
31 | }
32 | })
33 |
34 | pager.offscreenPageLimit = CACHE_PAGES_LIMIT
35 | }
36 |
37 | fun page(): Int {
38 | return pager!!.currentItem
39 | }
40 |
41 | fun page(page: Int) {
42 | pager!!.setCurrentItem(page, false)
43 | }
44 |
45 | fun go(change: Int) {
46 | pager!!.setCurrentItem(pager!!.currentItem + change, false)
47 | }
48 |
49 | private fun bookmark(page: Int) {
50 | metadata.lastReadPosition = page
51 | metadata.save(context)
52 | }
53 |
54 | fun resume() {
55 | page(metadata.lastReadPosition)
56 | }
57 |
58 | fun finish() {
59 | if(page() == book.pages - 1) bookmark(0)
60 | auditor.closed(library, book, page())
61 | }
62 |
63 | companion object {
64 | const val CACHE_PAGES_LIMIT = 2
65 | }
66 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/index_book_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
11 |
12 |
13 |
17 |
23 |
32 |
33 |
34 |
44 |
45 |
55 |
56 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BooksSearcher.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.withContext
5 | import java.util.*
6 | import java.util.regex.Pattern
7 |
8 | internal class BooksSearcher {
9 | var searchText = ""
10 | var filterIds: ArrayList = ArrayList()
11 |
12 | suspend fun search(library: Library): ArrayList {
13 | val books = ArrayList()
14 |
15 | withContext(Dispatchers.Default) {
16 | val searchTerms = parseSearchTerms()
17 |
18 | bookLoop@ for((key, b) in library.books) {
19 | if(filterIds.isNotEmpty() && !filterIds.contains(key)) continue@bookLoop
20 |
21 | val compareTitle = b.title.lowercase(Locale.getDefault())
22 | for(searchTerm in searchTerms) {
23 | if(searchTerm.startsWith("-")) {
24 | val realSearchTerm = searchTerm.substring(1)
25 | if(realSearchTerm == SPECIAL_LONG_BOOK) {
26 | if(b.pages >= LONG_BOOK_PAGES) continue@bookLoop
27 | }
28 | else if(compareTitle.contains(realSearchTerm)) continue@bookLoop
29 | }
30 | else {
31 | if(searchTerm == SPECIAL_LONG_BOOK) {
32 | if(b.pages < LONG_BOOK_PAGES) continue@bookLoop
33 | }
34 | else if(!compareTitle.contains(searchTerm)) continue@bookLoop
35 | }
36 | }
37 | books.add(b)
38 | }
39 | }
40 |
41 | return books
42 | }
43 |
44 | private fun parseSearchTerms(): ArrayList {
45 | val terms = ArrayList()
46 | val searchPattern = Pattern.compile("\"[^\"]*\"|[^ ]+")
47 | val matcher = searchPattern.matcher(searchText.lowercase())
48 | while(matcher.find()) terms.add(matcher.group().replace("\"", ""))
49 | return terms
50 | }
51 |
52 | companion object {
53 | const val LONG_BOOK_PAGES = 100
54 | const val SPECIAL_LONG_BOOK = "s.long"
55 | }
56 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BookList.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.ContentValues
4 | import android.content.Context
5 | import android.database.Cursor
6 | import java.util.ArrayList
7 |
8 | class BookList {
9 | var _id = -1L
10 | var name: String? = null
11 |
12 | constructor()
13 | constructor(result: Cursor) {
14 | _id = result["_id"]
15 | name = result["name"]
16 | }
17 |
18 | fun bookIds(context: Context): ArrayList {
19 | val db = DatabaseHelper.instance(context)
20 | db.rawQuery("SELECT book_id FROM lists_books WHERE list_id=?", arrayOf(_id.toString())).use {
21 | it.moveToFirst()
22 | val bookIds = ArrayList()
23 | while(it.moveToNext()) bookIds.add(it["book_id"])
24 | return bookIds
25 | }
26 | }
27 |
28 | fun bookIds(context: Context, bookIds: ArrayList) {
29 | val db = DatabaseHelper.instance(context)
30 | db.delete("lists_books", "list_id=?", arrayOf(_id.toString()))
31 | for(bookId in bookIds) {
32 | val values = ContentValues()
33 | values.put("list_id", _id)
34 | values.put("book_id", bookId)
35 | db.insert("lists_books", null, values)
36 | }
37 | }
38 |
39 | fun save(context: Context) {
40 | val values = ContentValues()
41 | values.put("name", name)
42 | val db = DatabaseHelper.instance(context)
43 | if(_id == -1L) {
44 | _id = db.insert("lists", null, values)
45 | }
46 | else {
47 | db.update("lists", values, "_id=?", arrayOf(_id.toString()))
48 | }
49 | }
50 |
51 | fun destroy(context: Context) {
52 | val db = DatabaseHelper.instance(context)
53 | db.delete("lists", "_id=?", arrayOf(_id.toString()))
54 | }
55 |
56 | companion object {
57 | fun findById(context: Context, id: Long): BookList? {
58 | val db = DatabaseHelper.instance(context)
59 | db.rawQuery("SELECT * FROM lists WHERE _id=?", arrayOf(id.toString())).use {
60 | it.moveToFirst()
61 | return if (it.count > 0) BookList(it) else null
62 | }
63 | }
64 | }
65 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/Query.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.ContentValues
4 | import android.content.Context
5 | import android.database.Cursor
6 |
7 | class Query {
8 | var _id = -1L
9 | var text: String? = null
10 | var createdAt: Long = 0
11 | var lastUsedAt: Long = 0
12 | var usedCount: Long = 0
13 |
14 | internal constructor()
15 | internal constructor(
16 | text: String?,
17 | createdAt: Long,
18 | lastUsedAt: Long,
19 | usedCount: Long
20 | ) {
21 | this.text = text
22 | this.createdAt = createdAt
23 | this.lastUsedAt = lastUsedAt
24 | this.usedCount = usedCount
25 | }
26 |
27 | internal constructor(result: Cursor) {
28 | _id = result["_id"]
29 | text = result["text"]
30 | createdAt = result["created_at"]
31 | lastUsedAt = result["last_used_at"]
32 | usedCount = result["used_count"]
33 | }
34 |
35 | fun save(context: Context) {
36 | val values = ContentValues()
37 | values.put("\"text\"", text)
38 | values.put("created_at", createdAt)
39 | values.put("last_used_at", lastUsedAt)
40 | values.put("used_count", usedCount)
41 | val db = DatabaseHelper.instance(context)
42 | if(_id == -1L) {
43 | _id = db.insertOrThrow("queries", null, values)
44 | }
45 | else {
46 | db.update("queries", values, "_id=?", arrayOf(_id.toString()))
47 | }
48 | }
49 |
50 | companion object {
51 | fun findById(context: Context, id: Long): Query? {
52 | val db = DatabaseHelper.instance(context)
53 | db.rawQuery("SELECT * FROM queries WHERE _id=?", arrayOf(id.toString())).use {
54 | it.moveToFirst()
55 | return if (it.count > 0) Query(it) else null
56 | }
57 | }
58 |
59 | fun findByText(context: Context, text: String): Query? {
60 | val db = DatabaseHelper.instance(context)
61 | db.rawQuery("SELECT * FROM queries WHERE \"text\"=?", arrayOf(text)).use {
62 | it.moveToFirst()
63 | return if (it.count > 0) Query(it) else null
64 | }
65 | }
66 | }
67 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BookPagerAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import androidx.fragment.app.FragmentActivity
4 | import androidx.viewpager2.adapter.FragmentStateAdapter
5 | import com.bumptech.glide.ListPreloader.PreloadModelProvider
6 | import com.bumptech.glide.load.model.GlideUrl
7 | import com.bumptech.glide.RequestManager
8 | import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
9 | import androidx.recyclerview.widget.RecyclerView
10 | import com.bumptech.glide.Glide
11 | import com.bumptech.glide.util.ViewPreloadSizeProvider
12 | import com.bumptech.glide.RequestBuilder
13 | import android.graphics.drawable.Drawable
14 | import androidx.fragment.app.Fragment
15 |
16 | internal class BookPagerAdapter(fa: FragmentActivity?, private val book: Book) : FragmentStateAdapter(
17 | fa!!
18 | ), PreloadModelProvider {
19 | private var requestManager: RequestManager? = null
20 | private var preloader: RecyclerViewPreloader? = null
21 |
22 | override fun createFragment(i: Int): Fragment {
23 | return PageFragment.newInstance(book.pageUrl(i))
24 | }
25 |
26 | override fun getItemCount(): Int {
27 | return book.pages
28 | }
29 |
30 | override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
31 | super.onAttachedToRecyclerView(recyclerView)
32 |
33 | requestManager = Glide.with(recyclerView)
34 | preloader = RecyclerViewPreloader(
35 | requestManager!!,
36 | this,
37 | ViewPreloadSizeProvider(recyclerView),
38 | ReadingSession.CACHE_PAGES_LIMIT
39 | )
40 |
41 | recyclerView.addOnScrollListener(preloader!!)
42 | }
43 |
44 | override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
45 | super.onDetachedFromRecyclerView(recyclerView)
46 | recyclerView.removeOnScrollListener(preloader!!)
47 | preloader = null
48 | requestManager = null
49 | }
50 |
51 | override fun getPreloadItems(position: Int): List {
52 | val pageUrl = book.pageUrl(position)
53 | return listOf(pageUrl.toGlideUrl())
54 | }
55 |
56 | override fun getPreloadRequestBuilder(url: GlideUrl): RequestBuilder {
57 | return requestManager!!.load(url).transform(MatchWidthTransformation())
58 | }
59 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/LibrariesAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.database.Cursor
4 | import androidx.recyclerview.widget.RecyclerView
5 | import android.widget.TextView
6 | import android.view.ViewGroup
7 | import android.view.LayoutInflater
8 | import android.view.View
9 |
10 | internal class LibrariesAdapter(private val fragment: LibrariesFragment, cursor: Cursor?) :
11 | CursorRecyclerAdapter(cursor) {
12 | private var selectedLibraryId: Long = 0
13 |
14 | fun setCurrentLibraryId(libraryId: Long) {
15 | selectedLibraryId = libraryId
16 | notifyDataSetChanged()
17 | }
18 |
19 | internal inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
20 | @JvmField
21 | var libraryId: Long = 0
22 | var nameView: TextView = view.findViewById(R.id.name)
23 | var currentNameView: TextView = view.findViewById(R.id.current_name)
24 |
25 | init {
26 | view.setOnClickListener {
27 | if(fragment.isEditingMode) fragment.edit(libraryId)
28 | else fragment.show(
29 | libraryId
30 | )
31 | }
32 |
33 | view.setOnLongClickListener {
34 | if(fragment.isEditingMode) {
35 | fragment.startDrag(this@ViewHolder)
36 | return@setOnLongClickListener true
37 | }
38 | else {
39 | return@setOnLongClickListener false
40 | }
41 | }
42 | }
43 | }
44 |
45 | // Create new views (invoked by the layout manager)
46 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
47 | val view = LayoutInflater.from(parent.context).inflate(R.layout.library, parent, false)
48 | return ViewHolder(view)
49 | }
50 |
51 | // Replace the contents of a view (invoked by the layout manager)
52 | override fun onBindViewHolder(holder: ViewHolder, cursor: Cursor) {
53 | val library = Library(cursor)
54 |
55 | holder.libraryId = library.id
56 |
57 | if(library.id == selectedLibraryId) {
58 | holder.currentNameView.text = library.name
59 | holder.nameView.visibility = View.GONE
60 | holder.currentNameView.visibility = View.VISIBLE
61 | }
62 | else {
63 | holder.nameView.text = library.name
64 | holder.currentNameView.visibility = View.GONE
65 | holder.nameView.visibility = View.VISIBLE
66 | }
67 | }
68 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/MangosUrl.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.net.Uri
4 | import android.os.Parcelable
5 | import com.bumptech.glide.load.model.GlideUrl
6 | import com.bumptech.glide.load.model.LazyHeaders
7 | import android.os.Parcel
8 | import android.os.Parcelable.Creator
9 | import android.util.Base64
10 | import coil3.network.NetworkHeaders
11 | import coil3.network.httpHeaders
12 | import coil3.request.ImageRequest
13 | import okhttp3.Request
14 |
15 | open class MangosUrl(private val url: String, private val credential: String? = null) : Parcelable {
16 | constructor(url: String, username: String?, password: String?) : this(
17 | url,
18 | if(username != null && password != null) {
19 | Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP)
20 | }
21 | else {
22 | null
23 | }
24 | )
25 |
26 | operator fun div(other: String): MangosUrl {
27 | return MangosUrl(url + "/" + Uri.encode(other), credential)
28 | }
29 |
30 | fun toOkHttpRequest(): Request {
31 | val builder = Request.Builder().url(url)
32 | if(credential != null) builder.header("Authorization", "Basic $credential")
33 | return builder.build()
34 | }
35 |
36 | fun toGlideUrl(): GlideUrl {
37 | if(credential != null) {
38 | return GlideUrl(url, LazyHeaders.Builder().addHeader("Authorization", "Basic $credential").build())
39 | }
40 | return GlideUrl(url)
41 | }
42 |
43 | fun loadInto(builder: ImageRequest.Builder): ImageRequest.Builder {
44 | return builder.apply {
45 | data(url)
46 | if(credential != null) {
47 | httpHeaders(NetworkHeaders.Builder().add("Authorization", "Basic $credential").build())
48 | }
49 | }
50 | }
51 |
52 | override fun describeContents(): Int {
53 | return 0
54 | }
55 |
56 | override fun writeToParcel(dest: Parcel, flags: Int) {
57 | dest.writeString(url)
58 | dest.writeString(credential)
59 | }
60 |
61 | protected constructor(input: Parcel) : this(input.readString()!!, input.readString())
62 |
63 | companion object {
64 | @JvmField
65 | val CREATOR: Creator = object : Creator {
66 | override fun createFromParcel(source: Parcel): MangosUrl {
67 | return MangosUrl(source)
68 | }
69 |
70 | override fun newArray(size: Int): Array {
71 | return arrayOfNulls(size)
72 | }
73 | }
74 | }
75 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/LibraryService.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import net.bloople.manga.Library.Companion.findById
4 | import net.bloople.manga.Library.Companion.findDefault
5 | import android.app.ProgressDialog
6 | import android.content.Context
7 | import android.util.LruCache
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.withContext
10 | import kotlinx.serialization.ExperimentalSerializationApi
11 | import kotlinx.serialization.json.Json
12 | import kotlinx.serialization.json.decodeFromStream
13 | import okhttp3.OkHttpClient
14 | import java.io.IOException
15 |
16 | object LibraryService {
17 | private const val LIBRARY_CACHE_MAX_COUNT = 5
18 | private val currentLibraries = LruCache(LIBRARY_CACHE_MAX_COUNT)
19 | private val okHttpClient = OkHttpClient()
20 |
21 | suspend fun ensureLibrary(context: Context, libraryId: Long): Library? {
22 | var library = findById(context, libraryId)
23 | if(library == null) library = findDefault(context)
24 |
25 | if(library == null) return null
26 |
27 | val current = currentLibraries[library.id]
28 | if(current != null && current.root == library.root) return current
29 |
30 | if(load(context, library)) {
31 | currentLibraries.put(library.id, library)
32 | return library
33 | }
34 |
35 | return null
36 | }
37 |
38 | private suspend fun load(context: Context, library: Library): Boolean {
39 | val loadingLibraryDialog = ProgressDialog.show(
40 | context,
41 | "Loading " + library.name,
42 | "Please wait while the library is loaded...",
43 | true
44 | )
45 |
46 | return inflate(library).also { loadingLibraryDialog.dismiss() }
47 | }
48 |
49 | @OptIn(ExperimentalSerializationApi::class)
50 | private suspend fun inflate(library: Library): Boolean {
51 | return try {
52 | inflateUnchecked(library)
53 | true
54 | }
55 | catch(e: Exception) {
56 | e.printStackTrace()
57 | false
58 | }
59 | }
60 |
61 | @ExperimentalSerializationApi
62 | private suspend fun inflateUnchecked(library: Library) {
63 | withContext(Dispatchers.IO) {
64 | val books: List
65 | val request = library.dataUrl.toOkHttpRequest()
66 |
67 | okHttpClient.newCall(request).execute().use {
68 | if(!it.isSuccessful) throw IOException("Request failed. Request: $request, Response: $it")
69 | books = Json.decodeFromStream(it.body!!.byteStream())
70 | }
71 |
72 | library.inflate(books)
73 | }
74 | }
75 | }
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 | id 'com.google.devtools.ksp' version '2.2.10-2.0.2'
5 | id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
6 | }
7 |
8 | android {
9 | compileSdk = 36
10 |
11 | defaultConfig {
12 | applicationId "net.bloople.manga"
13 | minSdk 30
14 | targetSdk 35
15 | final def version = 75
16 | versionCode version
17 | versionName version.toString()
18 | }
19 | buildTypes {
20 | release {
21 | minifyEnabled true
22 | shrinkResources true
23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
24 | }
25 | debug {
26 | applicationIdSuffix ".debug"
27 | }
28 | }
29 | kotlinOptions {
30 | freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
31 | }
32 | namespace 'net.bloople.manga'
33 | }
34 |
35 | java {
36 | toolchain {
37 | languageVersion = JavaLanguageVersion.of(21)
38 | }
39 | }
40 |
41 | dependencies {
42 | implementation 'androidx.core:core-ktx:1.17.0'
43 | implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
44 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
45 |
46 | implementation 'androidx.recyclerview:recyclerview:1.4.0'
47 | implementation 'androidx.appcompat:appcompat:1.7.1'
48 | implementation 'com.google.android.material:material:1.13.0'
49 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.4'
50 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4'
51 |
52 | implementation 'androidx.fragment:fragment-ktx:1.8.9'
53 | implementation 'androidx.drawerlayout:drawerlayout:1.2.0'
54 |
55 | implementation 'com.squareup.okhttp3:okhttp:4.12.0'
56 |
57 | def glideVersion = '4.16.0'
58 | implementation ("com.github.bumptech.glide:glide:${glideVersion}") {
59 | exclude group: 'com.android.support'
60 | }
61 | implementation ("com.github.bumptech.glide:recyclerview-integration:${glideVersion}") {
62 | transitive = false
63 | }
64 | implementation ("com.github.bumptech.glide:okhttp4-integration:${glideVersion}") {
65 | transitive = false
66 | }
67 | //implementation "com.github.zjupure:webpdecoder:2.3.${glideVersion}"
68 | implementation "com.github.bumptech.glide:annotations:${glideVersion}"
69 | ksp "com.github.bumptech.glide:ksp:${glideVersion}"
70 |
71 | def coilVersion = '3.3.0'
72 | implementation "io.coil-kt.coil3:coil:${coilVersion}"
73 | implementation "io.coil-kt.coil3:coil-network-okhttp:${coilVersion}"
74 | implementation "io.coil-kt.coil3:coil-gif:${coilVersion}"
75 |
76 | implementation "androidx.viewpager2:viewpager2:1.1.0"
77 | }
78 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/libraries_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
14 |
15 |
20 |
21 |
28 |
29 |
35 |
36 |
43 |
44 |
50 |
51 |
58 |
59 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/AuditEvent.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.content.ContentValues
4 | import android.content.Context
5 | import android.database.Cursor
6 | import net.bloople.manga.get
7 | import java.lang.IllegalArgumentException
8 |
9 | internal class AuditEvent {
10 | var _id = -1L
11 | var `when`: Long
12 | var action: Action? = null
13 | var resourceContextType: ResourceType
14 | var resourceContextId: Long
15 | var resourceType: ResourceType
16 | var resourceId: Long
17 | var resourceName: String
18 | var detail: String
19 |
20 | constructor(
21 | `when`: Long,
22 | action: Action?,
23 | resourceContextType: ResourceType,
24 | resourceContextId: Long,
25 | resourceType: ResourceType,
26 | resourceId: Long,
27 | resourceName: String,
28 | detail: String
29 | ) {
30 | this.`when` = `when`
31 | this.action = action
32 | this.resourceContextType = resourceContextType
33 | this.resourceContextId = resourceContextId
34 | this.resourceType = resourceType
35 | this.resourceId = resourceId
36 | this.resourceName = resourceName
37 | this.detail = detail
38 | }
39 |
40 | constructor(result: Cursor) {
41 | _id = result["_id"]
42 | `when` = result["when"]
43 | val actionText: String = result["action"]
44 | action = try {
45 | Action.valueOf(actionText)
46 | }
47 | catch(e: IllegalArgumentException) {
48 | Action.UNKNOWN
49 | }
50 | resourceContextType = ResourceType.valueOf(result["resource_context_type"])
51 | resourceContextId = result["resource_context_id"]
52 | resourceType = ResourceType.valueOf(result["resource_type"])
53 | resourceId = result["resource_id"]
54 | resourceName = result["resource_name"]
55 | detail = result["detail"]
56 | }
57 |
58 | fun save(context: Context) {
59 | val values = ContentValues()
60 | values.put("\"when\"", `when`)
61 | values.put("\"action\"", action.toString())
62 | values.put("resource_context_type", resourceContextType.toString())
63 | values.put("resource_context_id", resourceContextId)
64 | values.put("resource_type", resourceType.toString())
65 | values.put("resource_id", resourceId)
66 | values.put("resource_name", resourceName)
67 | values.put("detail", detail)
68 | val db = DatabaseHelper.instance(context)
69 | if(_id == -1L) {
70 | _id = db.insertOrThrow("audit_events", null, values)
71 | }
72 | else {
73 | db.update("audit_events", values, "_id=?", arrayOf(_id.toString()))
74 | }
75 | }
76 |
77 | companion object {
78 | var UNKNOWN_ID = -1L
79 | fun findById(context: Context, id: Long): AuditEvent? {
80 | val db = DatabaseHelper.instance(context)
81 | db.rawQuery("SELECT * FROM audit_events WHERE _id=?", arrayOf(id.toString())).use {
82 | it.moveToFirst()
83 | return if (it.count > 0) AuditEvent(it) else null
84 | }
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/ReadingActivity.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.res.Configuration
4 | import android.os.Bundle
5 | import android.widget.ImageView
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.core.view.WindowCompat
9 | import androidx.core.view.WindowInsetsCompat
10 | import androidx.core.view.WindowInsetsControllerCompat
11 | import androidx.lifecycle.lifecycleScope
12 | import androidx.viewpager2.widget.ViewPager2
13 | import kotlinx.coroutines.launch
14 | import net.bloople.manga.ThrottledOnClickListener.Companion.wrap
15 |
16 |
17 | class ReadingActivity : AppCompatActivity() {
18 | private lateinit var session: ReadingSession
19 | private lateinit var pager: ViewPager2
20 |
21 | override fun onCreate(savedInstanceState: Bundle?) {
22 | enableEdgeToEdge()
23 | super.onCreate(savedInstanceState)
24 |
25 | setContentView(R.layout.activity_reading)
26 |
27 | val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
28 | windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
29 | windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
30 |
31 | pager = findViewById(R.id.pager)
32 |
33 | val prev10 = findViewById(R.id.prev_10)
34 | prev10.setOnClickListener(wrap { session.go(-10) })
35 |
36 | val next10 = findViewById(R.id.next_10)
37 | next10.setOnClickListener(wrap { session.go(10) })
38 |
39 | val intent = intent
40 |
41 | val libraryId = intent.getLongExtra("libraryId", -1)
42 |
43 | lifecycleScope.launch {
44 | val library = LibraryService.ensureLibrary(this@ReadingActivity, libraryId) ?: return@launch
45 |
46 | val bookId = intent.getLongExtra("_id", -1)
47 | val book = library.books[bookId] ?: return@launch
48 |
49 | session = ReadingSession(applicationContext, library, book)
50 | session.bind(this@ReadingActivity, pager)
51 |
52 | if(intent.getBooleanExtra("resume", false)) session.resume()
53 | if(savedInstanceState != null) {
54 | val pageFromBundle = savedInstanceState.getInt("page", -1)
55 | if(pageFromBundle != -1) session.page(pageFromBundle)
56 | }
57 |
58 | session.start()
59 | }
60 | }
61 |
62 | public override fun onRestart() {
63 | super.onRestart()
64 | if(::session.isInitialized) session.start()
65 | }
66 |
67 | public override fun onSaveInstanceState(outState: Bundle) {
68 | outState.putInt("page", session.page())
69 | super.onSaveInstanceState(outState)
70 | }
71 |
72 | public override fun onStop() {
73 | if(::session.isInitialized) session.finish()
74 | super.onStop()
75 | }
76 |
77 | override fun onConfigurationChanged(newConfiguration: Configuration) {
78 | super.onConfigurationChanged(newConfiguration)
79 | val currentItem = pager.currentItem
80 | pager.adapter = pager.adapter
81 | pager.setCurrentItem(currentItem, false)
82 | }
83 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BookListAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import android.database.Cursor
5 | import android.view.KeyEvent
6 | import android.view.ViewGroup
7 | import android.view.LayoutInflater
8 | import android.widget.TextView
9 | import android.widget.EditText
10 | import android.view.inputmethod.EditorInfo
11 | import android.view.MotionEvent
12 | import android.view.View
13 | import android.view.inputmethod.InputMethodManager
14 | import android.widget.CursorAdapter
15 |
16 | internal class BookListAdapter(context: Context?, cursor: Cursor?) : CursorAdapter(context, cursor, 0) {
17 | // The newView method is used to inflate a new view and return it,
18 | // you don't bind any data to the view at this point.
19 | override fun newView(context: Context, cursor: Cursor, parent: ViewGroup): View {
20 | return LayoutInflater.from(context).inflate(R.layout.sidebar_item, parent, false)
21 | }
22 |
23 | // The bindView method is used to bind all data to a given view
24 | // such as setting the text on a TextView.
25 | override fun bindView(view: View, context: Context, cursor: Cursor) {
26 | // Find fields to populate in inflated template
27 | val nameView = view.findViewById(R.id.name)
28 | val editNameView = view.findViewById(R.id.edit_name)
29 |
30 | val listId: Long = cursor["_id"]
31 | val name: String = cursor["name"]
32 | nameView.text = name
33 |
34 | nameView.visibility = View.VISIBLE
35 | editNameView.visibility = View.GONE
36 |
37 | editNameView.setOnEditorActionListener { _: TextView?, actionId: Int, _: KeyEvent? ->
38 | if(actionId != EditorInfo.IME_ACTION_DONE) return@setOnEditorActionListener false
39 |
40 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
41 | imm.hideSoftInputFromWindow(editNameView.windowToken, 0)
42 | editNameView.clearFocus()
43 |
44 | val list = BookList.findById(context, listId)
45 | list!!.name = editNameView.text.toString()
46 | list.save(context)
47 |
48 | nameView.text = editNameView.text.toString()
49 | nameView.visibility = View.VISIBLE
50 | editNameView.visibility = View.GONE
51 |
52 | true
53 | }
54 |
55 | editNameView.setOnTouchListener { v: View?, event: MotionEvent ->
56 | val DRAWABLE_RIGHT = 2
57 |
58 | if(event.action == MotionEvent.ACTION_UP) {
59 | val clickIndex = editNameView.right -
60 | editNameView.compoundDrawables[DRAWABLE_RIGHT].bounds.width()
61 |
62 | if(event.rawX < clickIndex) return@setOnTouchListener false
63 |
64 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
65 | imm.hideSoftInputFromWindow(editNameView.windowToken, 0)
66 | editNameView.clearFocus()
67 |
68 | nameView.visibility = View.VISIBLE
69 | editNameView.visibility = View.GONE
70 |
71 | return@setOnTouchListener true
72 | }
73 |
74 | false
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/Library.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.ContentValues
4 | import android.content.Context
5 | import android.database.Cursor
6 | import android.net.Uri
7 | import java.util.HashMap
8 |
9 | class Library {
10 | var id = -1L
11 | var name: String? = null
12 | var position = 0
13 | var root: String? = null
14 | set(value) { field = Uri.parse(value).toString() }
15 | var username: String? = null
16 | var password: String? = null
17 | val books = HashMap()
18 |
19 | val rootUrl: MangosUrl by lazy { MangosUrl(root!!, username, password) }
20 | val mangos: MangosUrl by lazy { rootUrl / ".mangos" }
21 | val dataUrl: MangosUrl by lazy { mangos / DATA_JSON_PATH }
22 | val thumbnailsUrl: MangosUrl by lazy { mangos / "img" / "thumbnails" }
23 |
24 | internal constructor()
25 | internal constructor(result: Cursor) {
26 | id = result["_id"]
27 | name = result["name"]
28 | position = result["position"]
29 | root = result["root"]
30 | username = result["username"]
31 | password = result["password"]
32 | }
33 |
34 | fun inflate(deflatedBooks: List) {
35 | books.clear()
36 | books.putAll(deflatedBooks.associateBy { it.id })
37 | for(book in deflatedBooks) book.library = this
38 | }
39 |
40 | fun save(context: Context) {
41 | val values = ContentValues()
42 | values.put("name", name)
43 | values.put("position", position)
44 | values.put("root", root)
45 | values.put("username", username)
46 | values.put("password", password)
47 | val db = DatabaseHelper.instance(context)
48 | if(id == -1L) {
49 | id = db.insert("library_roots", null, values)
50 | }
51 | else {
52 | db.update("library_roots", values, "_id=?", arrayOf(id.toString()))
53 | }
54 | }
55 |
56 | fun destroy(context: Context) {
57 | val db = DatabaseHelper.instance(context)
58 | db.delete("library_roots", "_id=?", arrayOf(id.toString()))
59 | }
60 |
61 | companion object {
62 | private const val DATA_JSON_PATH = "data.json"
63 | @JvmStatic
64 | fun findById(context: Context, id: Long): Library? {
65 | val db = DatabaseHelper.instance(context)
66 | db.rawQuery("SELECT * FROM library_roots WHERE _id=?", arrayOf(id.toString())).use {
67 | it.moveToFirst()
68 | return if (it.count > 0) Library(it) else null
69 | }
70 | }
71 |
72 | @JvmStatic
73 | fun findDefault(context: Context): Library? {
74 | val db = DatabaseHelper.instance(context)
75 | db.rawQuery("SELECT * FROM library_roots ORDER BY position ASC LIMIT 1", arrayOf()).use {
76 | it.moveToFirst()
77 | return if (it.count > 0) Library(it) else null
78 | }
79 | }
80 |
81 | @JvmStatic
82 | fun findHighestPosition(context: Context): Int {
83 | val db = DatabaseHelper.instance(context)
84 | db.rawQuery("SELECT position FROM library_roots ORDER BY position DESC LIMIT 1", arrayOf()).use {
85 | it.moveToFirst()
86 | return if (it.count > 0) it["position"] else 0
87 | }
88 | }
89 | }
90 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BookMetadata.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.ContentValues
4 | import android.content.Context
5 | import android.database.Cursor
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.withContext
8 | import java.lang.StringBuilder
9 | import java.util.ArrayList
10 | import java.util.HashMap
11 |
12 | class BookMetadata {
13 | var _id = -1L
14 | var bookId: Long = 0
15 | var lastOpenedAt: Long = 0
16 | var lastReadPosition = 0
17 | var openedCount = 0
18 |
19 | constructor()
20 | constructor(result: Cursor) {
21 | _id = result["_id"]
22 | bookId = result["book_id"]
23 | lastOpenedAt = result["last_opened_at"]
24 | lastReadPosition = result["last_read_position"]
25 | openedCount = result["opened_count"]
26 | }
27 |
28 | fun save(context: Context) {
29 | val values = ContentValues()
30 | values.put("book_id", bookId)
31 | values.put("last_opened_at", lastOpenedAt)
32 | values.put("last_read_position", lastReadPosition)
33 | values.put("opened_count", openedCount)
34 | val db = DatabaseHelper.instance(context)
35 | if(_id == -1L) {
36 | _id = db.insertOrThrow("books_metadata", null, values)
37 | }
38 | else {
39 | db.update("books_metadata", values, "_id=?", arrayOf(_id.toString()))
40 | }
41 | }
42 |
43 | companion object {
44 | fun findById(context: Context, id: Long): BookMetadata? {
45 | val db = DatabaseHelper.instance(context)
46 | db.rawQuery("SELECT * FROM books_metadata WHERE _id=?", arrayOf(id.toString())).use {
47 | it.moveToFirst()
48 | return if (it.count > 0) BookMetadata(it) else null
49 | }
50 | }
51 |
52 | suspend fun findAllByBookIds(context: Context, books: ArrayList): HashMap {
53 | return withContext(Dispatchers.IO) {
54 | val db = DatabaseHelper.instance(context)
55 | val sb = StringBuilder()
56 | for(b in books) {
57 | if(sb.isNotEmpty()) sb.append(",")
58 | sb.append(b.id)
59 | }
60 |
61 | db.rawQuery("SELECT * FROM books_metadata WHERE book_id IN ($sb)", arrayOf()).use {
62 | it.moveToFirst()
63 | val booksMetadata = HashMap()
64 | while(it.moveToNext()) {
65 | val bookMetadata = BookMetadata(it)
66 | booksMetadata[bookMetadata.bookId] = bookMetadata
67 | }
68 | booksMetadata
69 | }
70 | }
71 | }
72 |
73 | fun findOrCreateByBookId(context: Context, bookId: Long): BookMetadata {
74 | val db = DatabaseHelper.instance(context)
75 | db.rawQuery("SELECT * FROM books_metadata WHERE book_id=?", arrayOf(bookId.toString())).use {
76 | it.moveToFirst()
77 | return if (it.count > 0) {
78 | BookMetadata(it)
79 | }
80 | else {
81 | val bookMetadata = BookMetadata()
82 | bookMetadata.bookId = bookId
83 | bookMetadata.save(context)
84 | bookMetadata
85 | }
86 | }
87 | }
88 | }
89 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/LibraryEditFragment.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.app.AlertDialog
4 | import android.app.Dialog
5 | import android.content.Context
6 | import net.bloople.manga.Library.Companion.findById
7 | import android.widget.EditText
8 | import android.os.Bundle
9 | import android.content.DialogInterface
10 | import androidx.fragment.app.DialogFragment
11 |
12 | class LibraryEditFragment : DialogFragment() {
13 | private var listener: OnLibraryEditFinishedListener? = null
14 | private var libraryId: Long = 0
15 | private lateinit var nameView: EditText
16 | private lateinit var rootView: EditText
17 | private lateinit var usernameView: EditText
18 | private lateinit var passwordView: EditText
19 |
20 | internal interface OnLibraryEditFinishedListener {
21 | fun onLibraryEditFinished(library: Library?)
22 | }
23 |
24 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
25 | val builder = AlertDialog.Builder(activity)
26 | builder.setTitle("Edit Library")
27 |
28 | val inflater = requireActivity().layoutInflater
29 | val view = inflater.inflate(R.layout.library_edit_fragment, null)
30 | builder.setView(view)
31 |
32 | nameView = view.findViewById(R.id.name)
33 | rootView = view.findViewById(R.id.root)
34 | usernameView = view.findViewById(R.id.username)
35 | passwordView = view.findViewById(R.id.password)
36 |
37 | val library = findById(requireContext(), libraryId)
38 | nameView.setText(library!!.name)
39 | rootView.setText(library.root)
40 | usernameView.setText(library.username)
41 | passwordView.setText(library.password)
42 |
43 | builder.setPositiveButton("Save") { _: DialogInterface?, _: Int -> update() }
44 | builder.setNegativeButton("Cancel") { _: DialogInterface?, _: Int -> cancel() }
45 | builder.setNeutralButton("Delete") { _: DialogInterface?, _: Int -> destroy() }
46 |
47 | return builder.create()
48 | }
49 |
50 | override fun onAttach(context: Context) {
51 | super.onAttach(context)
52 | listener = parentFragment as OnLibraryEditFinishedListener?
53 | }
54 |
55 | override fun onCreate(savedInstanceState: Bundle?) {
56 | super.onCreate(savedInstanceState)
57 | libraryId = requireArguments().getLong("libraryId", -1)
58 | }
59 |
60 | private fun cancel() {
61 | listener!!.onLibraryEditFinished(null)
62 | }
63 |
64 | private fun update() {
65 | val library = findById(requireContext(), libraryId)
66 | library!!.name = nameView.text.toString()
67 | library.root = rootView.text.toString()
68 | library.username = usernameView.text.toString()
69 | library.password = passwordView.text.toString()
70 | library.save(requireContext())
71 | listener!!.onLibraryEditFinished(library)
72 | }
73 |
74 | private fun destroy() {
75 | val library = findById(requireContext(), libraryId)
76 | library!!.destroy(requireContext())
77 | listener!!.onLibraryEditFinished(library)
78 | }
79 |
80 | companion object {
81 | fun newInstance(libraryId: Long): LibraryEditFragment {
82 | val fragment = LibraryEditFragment()
83 | val args = Bundle()
84 | args.putLong("libraryId", libraryId)
85 | fragment.arguments = args
86 | return fragment
87 | }
88 | }
89 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/DatabaseHelper.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.content.Context
4 | import android.database.sqlite.SQLiteDatabase
5 | import net.bloople.manga.get
6 | import java.io.FileInputStream
7 | import java.io.FileOutputStream
8 | import java.io.IOException
9 | import java.io.InputStream
10 | import java.io.OutputStream
11 | import kotlin.jvm.Synchronized
12 | import kotlin.Throws
13 |
14 | object DatabaseHelper {
15 | private const val DB_NAME = "audit"
16 | private lateinit var database: SQLiteDatabase
17 |
18 | private fun obtainDatabase(context: Context): SQLiteDatabase {
19 | val db = context.applicationContext.openOrCreateDatabase(DB_NAME, Context.MODE_PRIVATE, null)
20 | loadSchema(db)
21 | return db
22 | }
23 |
24 | private fun loadSchema(db: SQLiteDatabase) {
25 | db.execSQL(
26 | "CREATE TABLE IF NOT EXISTS audit_events ( " +
27 | "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
28 | "\"when\" INTEGER, " +
29 | "\"action\" TEXT, " +
30 | "resource_type TEXT, " +
31 | "resource_id INTEGER, " +
32 | "resource_name TEXT, " +
33 | "detail TEXT" +
34 | ")"
35 | )
36 |
37 | var alreadyHasResourceContext = false
38 |
39 | db.rawQuery("PRAGMA table_info(audit_events)", null).use {
40 | it.moveToFirst()
41 | while(it.moveToNext()) {
42 | if(it.get("name") == "resource_context_type") {
43 | alreadyHasResourceContext = true
44 | break
45 | }
46 | }
47 | }
48 |
49 | if(!alreadyHasResourceContext) {
50 | db.execSQL("ALTER TABLE audit_events ADD COLUMN resource_context_type TEXT")
51 | db.execSQL("ALTER TABLE audit_events ADD COLUMN resource_context_id INTEGER")
52 | }
53 | }
54 |
55 | @Synchronized
56 | fun instance(context: Context): SQLiteDatabase {
57 | if (!::database.isInitialized) {
58 | database = obtainDatabase(context)
59 | }
60 | return database
61 | }
62 |
63 | @Synchronized
64 | fun deleteDatabase(context: Context) {
65 | context.applicationContext.deleteDatabase(DB_NAME)
66 | database = obtainDatabase(context)
67 | }
68 |
69 | @Synchronized
70 | @Throws(IOException::class)
71 | fun exportDatabase(context: Context, outputStream: OutputStream) {
72 | val path = instance(context).use { it.path }
73 | database = obtainDatabase(context)
74 |
75 | var inputStream: InputStream? = null
76 | try {
77 | inputStream = FileInputStream(path)
78 | val buffer = ByteArray(1024)
79 | var length: Int
80 | while(inputStream.read(buffer).also { length = it } > 0) {
81 | outputStream.write(buffer, 0, length)
82 | }
83 | }
84 | finally {
85 | inputStream?.close()
86 | outputStream.close()
87 | }
88 | }
89 |
90 | @Synchronized
91 | @Throws(IOException::class)
92 | fun importDatabase(context: Context, inputStream: InputStream) {
93 | val path = instance(context).use { it.path }
94 | database = obtainDatabase(context)
95 |
96 | var outputStream: OutputStream? = null
97 | try {
98 | outputStream = FileOutputStream(path)
99 | val buffer = ByteArray(1024)
100 | var length: Int
101 | while(inputStream.read(buffer).also { length = it } > 0) {
102 | outputStream.write(buffer, 0, length)
103 | }
104 | }
105 | finally {
106 | inputStream.close()
107 | outputStream?.close()
108 | }
109 | }
110 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BooksSorter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.withContext
5 | import java.util.*
6 | import kotlin.collections.HashMap
7 |
8 | internal class BooksSorter {
9 | var sortMethod = BooksSortMethod.SORT_AGE
10 | var sortDirectionAsc = false
11 |
12 | fun flipSortDirection() {
13 | sortDirectionAsc = !sortDirectionAsc
14 | }
15 |
16 | fun description(): String {
17 | return "Sorted by ${sortMethodDescription().lowercase(Locale.getDefault())} ${sortDirectionDescription().lowercase(Locale.getDefault())}"
18 | }
19 |
20 | private fun sortMethodDescription(): String {
21 | return when(sortMethod) {
22 | BooksSortMethod.SORT_ALPHABETIC -> "Title"
23 | BooksSortMethod.SORT_AGE -> "Published Date"
24 | BooksSortMethod.SORT_LENGTH -> "Page Count"
25 | BooksSortMethod.SORT_LAST_OPENED -> "Last Opened At"
26 | BooksSortMethod.SORT_OPENED_COUNT -> "Opened Count"
27 | BooksSortMethod.SORT_RANDOM -> "Random"
28 | }
29 | }
30 |
31 | private fun sortDirectionDescription(): String {
32 | return if(sortDirectionAsc) "Ascending" else "Descending"
33 | }
34 |
35 | suspend fun sort(books: ArrayList, booksMetadata: HashMap) {
36 | return withContext(Dispatchers.Default) {
37 | if(books.isEmpty()) return@withContext
38 |
39 | if(sortMethod == BooksSortMethod.SORT_LAST_OPENED) {
40 | sortLastOpened(books, booksMetadata)
41 | }
42 | else if(sortMethod == BooksSortMethod.SORT_OPENED_COUNT) {
43 | sortOpenedCount(books, booksMetadata)
44 | }
45 | else if(sortMethod == BooksSortMethod.SORT_RANDOM) {
46 | sortRandom(books)
47 | }
48 | else {
49 | books.sortWith { a: Book, b: Book ->
50 | return@sortWith when(sortMethod) {
51 | BooksSortMethod.SORT_ALPHABETIC -> a.normalisedTitle.compareTo(b.normalisedTitle)
52 | BooksSortMethod.SORT_AGE -> a.publishedOn.compareTo(b.publishedOn)
53 | BooksSortMethod.SORT_LENGTH -> a.pages.compareTo(b.pages)
54 | else -> 0
55 | }
56 | }
57 | if(!sortDirectionAsc) books.reverse()
58 | }
59 | }
60 | }
61 |
62 | private fun sortLastOpened(books: ArrayList, booksMetadata: HashMap) {
63 | books.sortWith { a: Book, b: Book ->
64 | val abm = booksMetadata[a.id]
65 | val bbm = booksMetadata[b.id]
66 |
67 | if(abm == null && bbm == null) return@sortWith 0
68 | if(abm == null) return@sortWith 1
69 | if(bbm == null) return@sortWith -1
70 | if(sortDirectionAsc) return@sortWith abm.lastOpenedAt.compareTo(bbm.lastOpenedAt)
71 | else return@sortWith bbm.lastOpenedAt.compareTo(abm.lastOpenedAt)
72 | }
73 | }
74 |
75 | private fun sortOpenedCount(books: ArrayList, booksMetadata: HashMap) {
76 | books.sortWith { a: Book, b: Book ->
77 | val abm = booksMetadata[a.id]
78 | val bbm = booksMetadata[b.id]
79 |
80 | if(abm == null && bbm == null) return@sortWith 0
81 | if(abm == null) return@sortWith 1
82 | if(bbm == null) return@sortWith -1
83 | if(sortDirectionAsc) return@sortWith abm.openedCount.compareTo(bbm.openedCount)
84 | else return@sortWith bbm.openedCount.compareTo(abm.openedCount)
85 | }
86 | }
87 |
88 | private fun sortRandom(books: ArrayList) {
89 | val seed = System.currentTimeMillis() / (1000L * 60L * 30L)
90 | books.shuffle(Random(seed))
91 | if(!sortDirectionAsc) books.reverse()
92 | }
93 | }
94 |
95 | enum class BooksSortMethod {
96 | SORT_ALPHABETIC, SORT_AGE, SORT_LENGTH, SORT_LAST_OPENED, SORT_OPENED_COUNT, SORT_RANDOM
97 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_index.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
16 |
17 |
23 |
24 |
31 |
40 |
41 |
42 |
53 |
54 |
59 |
60 |
61 |
68 |
74 |
79 |
84 |
89 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/StringNextUtil.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import java.lang.StringBuilder
4 |
5 | // From: https://dzone.com/articles/java-implementation-stringnext
6 | // Original source: http://saltnlight5.blogspot.com.au/2013/01/java-implementation-of-stringnext.html
7 |
8 | /**
9 | * Utilities method for manipulating String.
10 | * @author zemian 1/1/13
11 | */
12 | internal object StringNextUtil {
13 | /** Calculate string successor value. Similar to Ruby's String#next() method. */
14 | fun next(text: String): String {
15 | // We will not process empty string
16 | val len = text.length
17 | if(len == 0) return text
18 |
19 | // Determine where does the first alpha-numeric starts.
20 | var alphaNum = false
21 | var alphaNumPos = -1
22 | for(c in text.toCharArray()) {
23 | alphaNumPos++
24 | if(Character.isDigit(c) || Character.isLetter(c)) {
25 | alphaNum = true
26 | break
27 | }
28 | }
29 |
30 | // Now we go calculate the next successor char of the given text.
31 | var buf = StringBuilder(text)
32 | if(!alphaNum || alphaNumPos == 0 || alphaNumPos == len) {
33 | // do the entire input text
34 | next(buf, buf.length - 1, alphaNum)
35 | }
36 | else {
37 | // Strip the input text for non alpha numeric prefix. We do not need to process these prefix but to save and
38 | // re-attach it later after the result.
39 | val prefix = text.substring(0, alphaNumPos)
40 | buf = StringBuilder(text.substring(alphaNumPos))
41 | next(buf, buf.length - 1, alphaNum)
42 | buf.insert(0, prefix)
43 | }
44 |
45 | // We are done.
46 | return buf.toString()
47 | }
48 |
49 | /** Internal method to calculate string successor value on alpha numeric chars only. */
50 | private fun next(buf: StringBuilder, pos: Int, alphaNum: Boolean) {
51 | // We are asked to carry over next value for the left most char
52 | if(pos == -1) {
53 | val c = buf[0]
54 | var rep: String? = null
55 | rep = when {
56 | Character.isDigit(c) -> "1"
57 | Character.isLowerCase(c) -> "a"
58 | Character.isUpperCase(c) -> "A"
59 | else -> (c.code + 1).toChar().toString()
60 | }
61 | buf.insert(0, rep)
62 | return
63 | }
64 |
65 | // We are asked to calculate next successor char for index of pos.
66 | val c = buf[pos]
67 | if(Character.isDigit(c)) {
68 | if(c == '9') {
69 | buf.replace(pos, pos + 1, "0")
70 | next(buf, pos - 1, alphaNum)
71 | }
72 | else {
73 | buf.replace(pos, pos + 1, (c.code + 1).toChar().toString())
74 | }
75 | }
76 | else if(Character.isLowerCase(c)) {
77 | if(c == 'z') {
78 | buf.replace(pos, pos + 1, "a")
79 | next(buf, pos - 1, alphaNum)
80 | }
81 | else {
82 | buf.replace(pos, pos + 1, (c.code + 1).toChar().toString())
83 | }
84 | }
85 | else if(Character.isUpperCase(c)) {
86 | if(c == 'Z') {
87 | buf.replace(pos, pos + 1, "A")
88 | next(buf, pos - 1, alphaNum)
89 | }
90 | else {
91 | buf.replace(pos, pos + 1, (c.code + 1).toChar().toString())
92 | }
93 | }
94 | else {
95 | // If input text has any alpha num at all then we are to calc next these characters only and ignore the
96 | // we will do this by recursively call into next char in buf.
97 | if(alphaNum) {
98 | next(buf, pos - 1, alphaNum)
99 | }
100 | else {
101 | // However if the entire input text is non alpha numeric, then we will calc successor by simply
102 | // increment to the next char in range (including non-printable char!)
103 | if(c == Character.MAX_VALUE) {
104 | buf.replace(pos, pos + 1, Character.MIN_VALUE.toString())
105 | next(buf, pos - 1, alphaNum)
106 | }
107 | else {
108 | buf.replace(pos, pos + 1, (c.code + 1).toChar().toString())
109 | }
110 | }
111 | }
112 | }
113 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/CursorRecyclerAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.database.Cursor
4 | import androidx.recyclerview.widget.RecyclerView
5 |
6 | /*
7 | * The MIT License (MIT)
8 | *
9 | * Copyright (c) 2015 ARNAUD FRUGIER
10 | *
11 | * Permission is hereby granted, free of charge, to any person obtaining a copy
12 | * of this software and associated documentation files (the "Software"), to deal
13 | * in the Software without restriction, including without limitation the rights
14 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 | * copies of the Software, and to permit persons to whom the Software is
16 | * furnished to do so, subject to the following conditions:
17 | *
18 | * The above copyright notice and this permission notice shall be included in all
19 | * copies or substantial portions of the Software.
20 | *
21 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27 | * SOFTWARE.
28 | */
29 |
30 | abstract class CursorRecyclerAdapter(c: Cursor?) : RecyclerView.Adapter() {
31 | private var cursor: Cursor? = null
32 | private var mRowIDColumn = 0
33 |
34 | fun init(c: Cursor?) {
35 | cursor = c
36 | mRowIDColumn = c?.getColumnIndexOrThrow("_id") ?: -1
37 | setHasStableIds(true)
38 | }
39 |
40 | override fun onBindViewHolder(holder: VH, position: Int) {
41 | check(cursor != null) { "this should only be called when the cursor is valid" }
42 | check(cursor!!.moveToPosition(position)) { "couldn't move cursor to position $position" }
43 | onBindViewHolder(holder, cursor!!)
44 | }
45 |
46 | abstract fun onBindViewHolder(holder: VH, cursor: Cursor)
47 |
48 | override fun getItemCount(): Int {
49 | return cursor?.count ?: 0
50 | }
51 |
52 | override fun getItemId(position: Int): Long {
53 | return if(hasStableIds() && cursor != null) {
54 | if(cursor!!.moveToPosition(position)) {
55 | cursor!!.getLong(mRowIDColumn)
56 | }
57 | else {
58 | RecyclerView.NO_ID
59 | }
60 | }
61 | else {
62 | RecyclerView.NO_ID
63 | }
64 | }
65 |
66 | /**
67 | * Change the underlying cursor to a new cursor. If there is an existing cursor it will be
68 | * closed.
69 | *
70 | * @param cursor The new cursor to be used
71 | */
72 | fun changeCursor(cursor: Cursor?) {
73 | val old = swapCursor(cursor)
74 | old?.close()
75 | }
76 |
77 | /**
78 | * Swap in a new Cursor, returning the old Cursor. Unlike
79 | * [.changeCursor], the returned old Cursor is *not*
80 | * closed.
81 | *
82 | * @param newCursor The new cursor to be used.
83 | * @return Returns the previously set Cursor, or null if there wasa not one.
84 | * If the given new Cursor is the same instance is the previously set
85 | * Cursor, null is also returned.
86 | */
87 | fun swapCursor(newCursor: Cursor?): Cursor? {
88 | if(newCursor === cursor) {
89 | return null
90 | }
91 |
92 | val oldCursor = cursor
93 | val itemCount = itemCount
94 | cursor = newCursor
95 | if(newCursor != null) {
96 | mRowIDColumn = newCursor.getColumnIndexOrThrow("_id")
97 | // notify the observers about the new cursor
98 | notifyDataSetChanged()
99 | }
100 | else {
101 | mRowIDColumn = -1
102 | // notify the observers about the lack of a data set
103 | notifyItemRangeRemoved(0, itemCount)
104 | }
105 | return oldCursor
106 | }
107 |
108 | /**
109 | *
110 | * Converts the cursor into a CharSequence. Subclasses should override this
111 | * method to convert their results. The default implementation returns an
112 | * empty String for null values or the default String representation of
113 | * the value.
114 | *
115 | * @param cursor the cursor to convert to a CharSequence
116 | * @return a CharSequence representing the value
117 | */
118 | fun convertToString(cursor: Cursor?): CharSequence {
119 | return cursor?.toString() ?: ""
120 | }
121 |
122 | init {
123 | init(c)
124 | }
125 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/CollectionsManager.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import android.widget.ImageButton
5 | import android.widget.AdapterView.OnItemClickListener
6 | import android.widget.AdapterView
7 | import android.widget.AdapterView.OnItemLongClickListener
8 | import android.widget.TextView
9 | import android.widget.EditText
10 | import android.view.View
11 | import android.view.inputmethod.InputMethodManager
12 | import android.widget.Button
13 | import android.widget.ListView
14 |
15 | internal class CollectionsManager(private val activity: IndexActivity, private val adapter: BooksAdapter) {
16 | private lateinit var bookListAdapter: BookListAdapter
17 | private lateinit var newCollection: ImageButton
18 | private lateinit var saveCollection: Button
19 | private lateinit var editCollection: ImageButton
20 | private lateinit var destroyCollection: ImageButton
21 | private lateinit var sidebar: ListView
22 | private var list: BookList? = null
23 |
24 | fun setup() {
25 | sidebar = activity.findViewById(R.id.sidebar)
26 | bookListAdapter = BookListAdapter(activity, null)
27 | sidebar.adapter = bookListAdapter
28 | updateCursor()
29 |
30 | sidebar.onItemClickListener = OnItemClickListener { parent: AdapterView<*>, view: View, position: Int, id: Long ->
31 | list = if(position == 0) null
32 | else BookList.findById(activity, parent.getItemIdAtPosition(position))
33 |
34 | activity.useList(list)
35 |
36 | view.isActivated = true
37 |
38 | if(list == null) {
39 | adapter.clearSelectedBookIds()
40 | adapter.setSelectable(false)
41 |
42 | newCollection.visibility = View.VISIBLE
43 | editCollection.visibility = View.GONE
44 | destroyCollection.visibility = View.GONE
45 | saveCollection.visibility = View.GONE
46 | }
47 | else {
48 | newCollection.visibility = View.GONE
49 | editCollection.visibility = View.VISIBLE
50 | destroyCollection.visibility = View.VISIBLE
51 | }
52 | }
53 |
54 | sidebar.onItemLongClickListener =
55 | OnItemLongClickListener { _: AdapterView<*>?, view: View, _: Int, _: Long ->
56 | val nameView = view.findViewById(R.id.name)
57 | val editNameView = view.findViewById(R.id.edit_name)
58 |
59 | if(nameView == null) return@OnItemLongClickListener false
60 |
61 | editNameView.setText(nameView.text)
62 | nameView.visibility = View.GONE
63 | editNameView.visibility = View.VISIBLE
64 |
65 | editNameView.requestFocusFromTouch()
66 | val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
67 | imm.showSoftInput(editNameView, 0)
68 |
69 | true
70 | }
71 |
72 | newCollection = activity.findViewById(R.id.new_collection)
73 | newCollection.setOnClickListener { newCollection() }
74 |
75 | saveCollection = activity.findViewById(R.id.save_collection)
76 | saveCollection.setOnClickListener { updateCollection() }
77 |
78 | editCollection = activity.findViewById(R.id.edit_collection)
79 | editCollection.setOnClickListener { editCollection() }
80 |
81 | destroyCollection = activity.findViewById(R.id.destroy_collection)
82 | destroyCollection.setOnClickListener { destroyCollection() }
83 |
84 | newCollection.visibility = View.VISIBLE
85 | editCollection.visibility = View.GONE
86 | destroyCollection.visibility = View.GONE
87 | saveCollection.visibility = View.GONE
88 | }
89 |
90 | private fun newCollection() {
91 | list = BookList()
92 | list!!.name = "New Collection"
93 | list!!.save(activity)
94 |
95 | updateCursor()
96 |
97 | adapter.clearSelectedBookIds()
98 | adapter.setSelectable(true)
99 |
100 | newCollection.visibility = View.GONE
101 | editCollection.visibility = View.GONE
102 | saveCollection.visibility = View.VISIBLE
103 | }
104 |
105 | private fun editCollection() {
106 | activity.useList(null)
107 | adapter.setSelectedBookIds(list!!.bookIds(activity))
108 | adapter.setSelectable(true)
109 | newCollection.visibility = View.GONE
110 | editCollection.visibility = View.GONE
111 | saveCollection.visibility = View.VISIBLE
112 | }
113 |
114 | private fun updateCollection() {
115 | list!!.bookIds(activity, adapter.getSelectedBookIds())
116 |
117 | adapter.clearSelectedBookIds()
118 | adapter.setSelectable(false)
119 |
120 | activity.useList(list)
121 |
122 | updateCursor()
123 |
124 | newCollection.visibility = View.GONE
125 | editCollection.visibility = View.VISIBLE
126 | saveCollection.visibility = View.GONE
127 | }
128 |
129 | private fun destroyCollection() {
130 | list!!.destroy(activity)
131 | updateCursor()
132 | }
133 |
134 | private fun updateCursor() {
135 | val db = DatabaseHelper.instance(activity)
136 | val result = db.rawQuery("SELECT * FROM lists", arrayOf())
137 | bookListAdapter.changeCursor(result)
138 | }
139 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/DatabaseManagementFragment.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.widget.ImageButton
4 | import android.os.Bundle
5 | import android.view.LayoutInflater
6 | import android.view.ViewGroup
7 | import android.content.Intent
8 | import android.app.Activity
9 | import android.view.View
10 | import android.widget.Toast
11 | import androidx.fragment.app.Fragment
12 | import java.io.IOException
13 |
14 | class DatabaseManagementFragment : Fragment() {
15 | private lateinit var importDatabaseButton: ImageButton
16 | private lateinit var exportDatabaseButton: ImageButton
17 |
18 | override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, savedInstanceState: Bundle?): View? {
19 | return inflater.inflate(R.layout.database_management_fragment, parent, false)
20 | }
21 |
22 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
23 | super.onViewCreated(view, savedInstanceState)
24 |
25 | exportDatabaseButton = view.findViewById(R.id.export_database)
26 | exportDatabaseButton.setOnClickListener { startExport() }
27 |
28 | importDatabaseButton = view.findViewById(R.id.import_database)
29 | importDatabaseButton.setOnClickListener { startImport() }
30 | }
31 |
32 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
33 | if(requestCode == REQUEST_CODE_EXPORT && resultCode == Activity.RESULT_OK) completeExport(data)
34 | else if(requestCode == REQUEST_CODE_EXPORT_AUDIT && resultCode == Activity.RESULT_OK) completeExportAudit(
35 | data
36 | )
37 | else if(requestCode == REQUEST_CODE_IMPORT && resultCode == Activity.RESULT_OK) completeImport(data)
38 | else if(requestCode == REQUEST_CODE_IMPORT_AUDIT && resultCode == Activity.RESULT_OK) completeImportAudit(
39 | data
40 | )
41 | }
42 |
43 | private fun startExport() {
44 | val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
45 | intent.addCategory(Intent.CATEGORY_OPENABLE)
46 | intent.type = "application/vnd.sqlite3"
47 | intent.putExtra(Intent.EXTRA_TITLE, "Manga.db")
48 | startActivityForResult(intent, REQUEST_CODE_EXPORT)
49 | }
50 |
51 | private fun completeExport(data: Intent?) {
52 | try {
53 | val outputStream = requireContext().contentResolver.openOutputStream(data!!.data!!)
54 | DatabaseHelper.exportDatabase(requireContext(), outputStream!!)
55 | Toast.makeText(context, "Database exported successfully", Toast.LENGTH_LONG).show()
56 | startExportAudit()
57 | }
58 | catch(e: IOException) {
59 | e.printStackTrace()
60 | Toast.makeText(context, "Error", Toast.LENGTH_LONG).show()
61 | }
62 | }
63 |
64 | private fun startExportAudit() {
65 | val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
66 | intent.addCategory(Intent.CATEGORY_OPENABLE)
67 | intent.type = "application/vnd.sqlite3"
68 | intent.putExtra(Intent.EXTRA_TITLE, "MangaAudit.db")
69 | startActivityForResult(intent, REQUEST_CODE_EXPORT_AUDIT)
70 | }
71 |
72 | private fun completeExportAudit(data: Intent?) {
73 | try {
74 | val outputStream = requireContext().contentResolver.openOutputStream(data!!.data!!)
75 | net.bloople.manga.audit.DatabaseHelper.exportDatabase(requireContext(), outputStream!!)
76 | Toast.makeText(context, "Audit Database exported successfully", Toast.LENGTH_LONG).show()
77 | }
78 | catch(e: IOException) {
79 | e.printStackTrace()
80 | Toast.makeText(context, "Error", Toast.LENGTH_LONG).show()
81 | }
82 | }
83 |
84 | private fun startImport() {
85 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
86 | intent.addCategory(Intent.CATEGORY_OPENABLE)
87 | intent.type = "*/*"
88 | startActivityForResult(intent, REQUEST_CODE_IMPORT)
89 | }
90 |
91 | private fun completeImport(data: Intent?) {
92 | try {
93 | val inputStream = requireContext().contentResolver.openInputStream(data!!.data!!)
94 | DatabaseHelper.importDatabase(requireContext(), inputStream!!)
95 | Toast.makeText(context, "Database imported successfully", Toast.LENGTH_LONG).show()
96 | startImportAudit()
97 | }
98 | catch(e: IOException) {
99 | e.printStackTrace()
100 | Toast.makeText(context, "Error", Toast.LENGTH_LONG).show()
101 | }
102 | }
103 |
104 | private fun startImportAudit() {
105 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
106 | intent.addCategory(Intent.CATEGORY_OPENABLE)
107 | intent.type = "*/*"
108 | startActivityForResult(intent, REQUEST_CODE_IMPORT_AUDIT)
109 | }
110 |
111 | private fun completeImportAudit(data: Intent?) {
112 | try {
113 | val inputStream = requireContext().contentResolver.openInputStream(data!!.data!!)
114 | net.bloople.manga.audit.DatabaseHelper.importDatabase(requireContext(), inputStream!!)
115 | Toast.makeText(context, "Audit Database imported successfully", Toast.LENGTH_LONG).show()
116 |
117 | val activity: Activity? = activity
118 | activity?.recreate()
119 | }
120 | catch(e: IOException) {
121 | e.printStackTrace()
122 | Toast.makeText(context, "Error", Toast.LENGTH_LONG).show()
123 | }
124 | }
125 |
126 | companion object {
127 | private const val REQUEST_CODE_EXPORT = 0
128 | private const val REQUEST_CODE_EXPORT_AUDIT = 1
129 | private const val REQUEST_CODE_IMPORT = 2
130 | private const val REQUEST_CODE_IMPORT_AUDIT = 3
131 | }
132 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/DatabaseHelper.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.database.sqlite.SQLiteDatabase
4 | import android.content.ContentValues
5 | import android.content.Context
6 | import java.io.FileInputStream
7 | import java.io.FileOutputStream
8 | import java.io.IOException
9 | import java.io.InputStream
10 | import java.io.OutputStream
11 | import kotlin.jvm.Synchronized
12 | import kotlin.Throws
13 |
14 | internal object DatabaseHelper {
15 | private const val DB_NAME = "books"
16 | private lateinit var database: SQLiteDatabase
17 |
18 | private fun obtainDatabase(context: Context): SQLiteDatabase {
19 | val db = context.applicationContext.openOrCreateDatabase(DB_NAME, Context.MODE_PRIVATE, null)
20 | loadSchema(db)
21 | return db
22 | }
23 |
24 | private fun loadSchema(db: SQLiteDatabase) {
25 | db.execSQL(
26 | "CREATE TABLE IF NOT EXISTS books ( " +
27 | "key TEXT PRIMARY KEY, " +
28 | "last_opened_at INTEGER DEFAULT 0" +
29 | ")"
30 | )
31 |
32 | db.execSQL(
33 | "CREATE TABLE IF NOT EXISTS lists ( " +
34 | "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
35 | "name TEXT" +
36 | ")"
37 | )
38 |
39 | db.execSQL(
40 | "CREATE TABLE IF NOT EXISTS lists_books ( " +
41 | "list_id INTEGER, " +
42 | "book_id INTEGER" +
43 | ")"
44 | )
45 |
46 | val result = db.rawQuery("SELECT COUNT(*) FROM lists", arrayOf())
47 | result.moveToFirst()
48 |
49 | if(result.getInt(0) == 0) {
50 | val values = ContentValues()
51 | values.put("name", "All Books")
52 | db.insert("lists", null, values)
53 | }
54 | result.close()
55 |
56 | createBooksMetadataSchema(db)
57 |
58 | createLibraryRootsSchema(db)
59 |
60 | createQueriesSchema(db)
61 | }
62 |
63 | @Synchronized
64 | fun instance(context: Context): SQLiteDatabase {
65 | if (!::database.isInitialized) {
66 | database = obtainDatabase(context)
67 | }
68 | return database
69 | }
70 |
71 | @Synchronized
72 | fun deleteDatabase(context: Context) {
73 | context.applicationContext.deleteDatabase(DB_NAME)
74 | database = obtainDatabase(context)
75 | }
76 |
77 | @Synchronized
78 | @Throws(IOException::class)
79 | fun exportDatabase(context: Context, outputStream: OutputStream) {
80 | val path = instance(context).use { it.path }
81 | database = obtainDatabase(context)
82 |
83 | var inputStream: InputStream? = null
84 | try {
85 | inputStream = FileInputStream(path)
86 | val buffer = ByteArray(1024)
87 | var length: Int
88 | while(inputStream.read(buffer).also { length = it } > 0) {
89 | outputStream.write(buffer, 0, length)
90 | }
91 | }
92 | finally {
93 | inputStream?.close()
94 | outputStream.close()
95 | }
96 | }
97 |
98 | @Synchronized
99 | @Throws(IOException::class)
100 | fun importDatabase(context: Context, inputStream: InputStream) {
101 | val path = instance(context).use { it.path }
102 | database = obtainDatabase(context)
103 |
104 | var outputStream: OutputStream? = null
105 | try {
106 | outputStream = FileOutputStream(path)
107 | val buffer = ByteArray(1024)
108 | var length: Int
109 | while(inputStream.read(buffer).also { length = it } > 0) {
110 | outputStream.write(buffer, 0, length)
111 | }
112 | }
113 | finally {
114 | inputStream.close()
115 | outputStream?.close()
116 | }
117 | }
118 |
119 | private fun createBooksMetadataSchema(db: SQLiteDatabase) {
120 | db.execSQL(
121 | "CREATE TABLE IF NOT EXISTS books_metadata ( " +
122 | "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
123 | "book_id INTEGER, " +
124 | "last_opened_at INTEGER DEFAULT 0, " +
125 | "last_read_position INTEGER DEFAULT 0" +
126 | ")"
127 | )
128 |
129 | if(!hasColumn(db, "books_metadata", "opened_count")) {
130 | db.execSQL("ALTER TABLE books_metadata ADD COLUMN opened_count INTEGER")
131 | db.execSQL("UPDATE books_metadata SET opened_count=0")
132 | }
133 | }
134 |
135 | private fun createLibraryRootsSchema(db: SQLiteDatabase) {
136 | db.execSQL(
137 | "CREATE TABLE IF NOT EXISTS library_roots ( " +
138 | "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
139 | "name TEXT, " +
140 | "root TEXT" +
141 | ")"
142 | )
143 |
144 | if(!hasColumn(db, "library_roots", "position")) {
145 | db.execSQL("ALTER TABLE library_roots ADD COLUMN position INTEGER")
146 | db.execSQL("UPDATE library_roots SET position=_id")
147 | }
148 |
149 | val result = db.rawQuery("SELECT COUNT(*) FROM library_roots", arrayOf())
150 | result.moveToFirst()
151 |
152 | if(!hasColumn(db, "library_roots", "username")) {
153 | db.execSQL("ALTER TABLE library_roots ADD COLUMN username TEXT")
154 | db.execSQL("ALTER TABLE library_roots ADD COLUMN password TEXT")
155 | }
156 |
157 | if(result.getInt(0) == 0) {
158 | val values = ContentValues()
159 | values.put("name", "Manga")
160 | values.put("root", "http://192.168.1.100:9292/h/Manga-OG/")
161 | db.insert("library_roots", null, values)
162 | }
163 | result.close()
164 | }
165 |
166 | private fun createQueriesSchema(db: SQLiteDatabase) {
167 | db.execSQL(
168 | "CREATE TABLE IF NOT EXISTS queries ( " +
169 | "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
170 | "\"text\" TEXT, " +
171 | "created_at INTEGER, " +
172 | "last_used_at INTEGER, " +
173 | "used_count INTEGER" +
174 | ")"
175 | )
176 | }
177 |
178 | private fun hasColumn(db: SQLiteDatabase, tableName: String, columnName: String): Boolean {
179 | db.rawQuery("PRAGMA table_info($tableName)", null).use {
180 | while (it.moveToNext()) {
181 | if (it.get("name") == columnName) {
182 | return true
183 | }
184 | }
185 | }
186 | return false
187 | }
188 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/audit/AuditEventsAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga.audit
2 |
3 | import android.app.AppComponentFactory
4 | import net.bloople.manga.CursorRecyclerAdapter
5 | import androidx.recyclerview.widget.RecyclerView
6 | import android.widget.TextView
7 | import android.widget.ImageButton
8 | import android.content.Intent
9 | import android.database.Cursor
10 | import net.bloople.manga.ReadingActivity
11 | import net.bloople.manga.IndexActivity
12 | import android.view.LayoutInflater
13 | import net.bloople.manga.R
14 | import android.widget.PopupWindow
15 | import android.view.ViewGroup
16 | import android.view.Gravity
17 | import android.view.View
18 | import android.widget.ImageView
19 | import androidx.appcompat.app.AppCompatActivity
20 | import androidx.lifecycle.lifecycleScope
21 | import net.bloople.manga.LibraryService
22 | import net.bloople.manga.Library
23 | import com.bumptech.glide.Glide
24 | import kotlinx.coroutines.Dispatchers
25 | import kotlinx.coroutines.launch
26 | import kotlinx.coroutines.runBlocking
27 | import java.text.SimpleDateFormat
28 | import java.util.*
29 |
30 | internal class AuditEventsAdapter(cursor: Cursor?) : CursorRecyclerAdapter(cursor) {
31 | internal inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
32 | var event: AuditEvent? = null
33 | var whenView: TextView = view.findViewById(R.id.`when`)
34 | var actionView: TextView = view.findViewById(R.id.action)
35 | var imageView: ImageView = view.findViewById(R.id.image_view)
36 | var openResourceView: ImageButton = view.findViewById(R.id.open_resource)
37 | var resourceNameView: TextView = view.findViewById(R.id.resource_name)
38 | var detailView: TextView = view.findViewById(R.id.detail)
39 |
40 | init {
41 | imageView.setOnClickListener { openResource(event) }
42 | openResourceView.setOnClickListener { openResource(event) }
43 | resourceNameView.setOnClickListener {
44 | showFullResourceName(
45 | event!!.resourceName
46 | )
47 | }
48 | }
49 |
50 | private fun openResource(event: AuditEvent?) {
51 | if(event!!.resourceType == ResourceType.BOOK && event.resourceContextType == ResourceType.LIBRARY) {
52 | openBook(event.resourceContextId, event.resourceId)
53 | }
54 | else if(event.resourceType == ResourceType.LIBRARY) {
55 | openLibrary(event.resourceId)
56 | }
57 | }
58 |
59 | private fun openBook(libraryId: Long, bookId: Long) {
60 | val intent = Intent(openResourceView.context, ReadingActivity::class.java)
61 | intent.putExtra("_id", bookId)
62 | intent.putExtra("resume", true)
63 | intent.putExtra("libraryId", libraryId)
64 | openResourceView.context.startActivity(intent)
65 | }
66 |
67 | private fun openLibrary(libraryId: Long) {
68 | val intent = Intent(openResourceView.context, IndexActivity::class.java)
69 | intent.putExtra("libraryId", libraryId)
70 | openResourceView.context.startActivity(intent)
71 | }
72 |
73 | private fun showFullResourceName(resourceName: String) {
74 | val popupView = LayoutInflater.from(resourceNameView.context).inflate(
75 | R.layout.audit_audit_event_resource_name_popup,
76 | null,
77 | false
78 | )
79 |
80 | val resourceNamePopupView = popupView.findViewById(R.id.resource_name)
81 | resourceNamePopupView.text = resourceName
82 | val popupWidth =
83 | resourceNameView.width + resourceNamePopupView.paddingStart + resourceNamePopupView.paddingEnd
84 | val popup = PopupWindow(popupView, popupWidth, ViewGroup.LayoutParams.WRAP_CONTENT)
85 |
86 | popup.isFocusable = true
87 | popup.isOutsideTouchable = true
88 | popup.elevation = 24f
89 | popupView.setOnClickListener { popup.dismiss() }
90 | popup.showAsDropDown(
91 | resourceNameView,
92 | -resourceNamePopupView.paddingStart,
93 | -resourceNameView.height,
94 | Gravity.TOP or Gravity.START
95 | )
96 | }
97 | }
98 |
99 | private val DATE_FORMAT = SimpleDateFormat(
100 | "d MMM yyyy h:mm a",
101 | Locale.getDefault()
102 | )
103 |
104 | // Create new views (invoked by the layout manager)
105 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
106 | val view = LayoutInflater.from(parent.context).inflate(
107 | R.layout.audit_audit_event,
108 | parent,
109 | false
110 | )
111 | return ViewHolder(view)
112 | }
113 |
114 | // Replace the contents of a view (invoked by the layout manager)
115 | override fun onBindViewHolder(holder: ViewHolder, cursor: Cursor) {
116 | val event = AuditEvent(cursor)
117 | holder.event = event
118 |
119 | val age = DATE_FORMAT.format(Date(event.`when`))
120 | holder.whenView.text = age
121 | holder.actionView.text = event.action.toString()
122 | holder.resourceNameView.text = event.resourceName
123 | holder.detailView.text = event.detail
124 |
125 | if(event.resourceType == ResourceType.BOOK && event.resourceContextType == ResourceType.LIBRARY) {
126 | renderBook(holder, event);
127 | return
128 | }
129 |
130 | holder.openResourceView.visibility = View.VISIBLE
131 | holder.imageView.visibility = View.GONE
132 |
133 | Glide.with(holder.imageView.context).clear(holder.imageView)
134 | }
135 |
136 | private fun renderBook(holder: ViewHolder, event: AuditEvent) {
137 | val context = holder.imageView.context as AppCompatActivity
138 | context.lifecycleScope.launch {
139 | val library = LibraryService.ensureLibrary(context, event.resourceContextId)!!
140 |
141 | holder.openResourceView.visibility = View.GONE
142 | holder.imageView.visibility = View.VISIBLE
143 |
144 | val viewWidthToBitmapWidthRatio = holder.imageView.layoutParams.width.toDouble() / 197.0
145 | holder.imageView.layoutParams.height = (310.0 * viewWidthToBitmapWidthRatio).toInt()
146 |
147 | val glide = Glide.with(holder.imageView.context)
148 |
149 | val book = library.books[event.resourceId]
150 |
151 | if(book != null) glide.load(book.thumbnailUrl.toGlideUrl()).into(holder.imageView)
152 | else glide.clear(holder.imageView)
153 | }
154 | }
155 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/LibrariesFragment.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.content.Context
4 | import net.bloople.manga.Library.Companion.findHighestPosition
5 | import net.bloople.manga.Library.Companion.findById
6 | import net.bloople.manga.LibraryEditFragment.OnLibraryEditFinishedListener
7 | import android.widget.ImageButton
8 | import androidx.recyclerview.widget.ItemTouchHelper
9 | import android.os.Bundle
10 | import android.view.LayoutInflater
11 | import android.view.ViewGroup
12 | import androidx.recyclerview.widget.RecyclerView
13 | import androidx.recyclerview.widget.LinearLayoutManager
14 | import android.content.Intent
15 | import net.bloople.manga.audit.AuditEventsActivity
16 | import android.view.View
17 | import android.widget.Toast
18 | import androidx.fragment.app.Fragment
19 | import androidx.lifecycle.lifecycleScope
20 | import kotlinx.coroutines.Dispatchers
21 | import kotlinx.coroutines.launch
22 |
23 | class LibrariesFragment : Fragment(), OnLibraryEditFinishedListener {
24 | private var listener: OnLibrarySelectedListener? = null
25 | private lateinit var librariesAdapter: LibrariesAdapter
26 | private lateinit var databaseManagementFragment: DatabaseManagementFragment
27 |
28 | private lateinit var viewAuditEventsButton: ImageButton
29 | private lateinit var startEditingButton: ImageButton
30 | private lateinit var finishEditingButton: ImageButton
31 | private lateinit var newLibraryButton: ImageButton
32 | private lateinit var clearCacheButton: ImageButton
33 | private lateinit var touchHelper: ItemTouchHelper
34 | var isEditingMode = false
35 | private set
36 |
37 | internal interface OnLibrarySelectedListener {
38 | fun onLibrarySelected(libraryId: Long)
39 | }
40 |
41 | override fun onAttach(context: Context) {
42 | super.onAttach(context)
43 | listener = context as OnLibrarySelectedListener
44 | }
45 |
46 | override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, savedInstanceState: Bundle?): View? {
47 | return inflater.inflate(R.layout.libraries_fragment, parent, false)
48 | }
49 |
50 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
51 | super.onViewCreated(view, savedInstanceState)
52 |
53 | databaseManagementFragment = childFragmentManager
54 | .findFragmentById(R.id.database_management_framework) as DatabaseManagementFragment
55 |
56 | val librariesView: RecyclerView = view.findViewById(R.id.libraries)
57 | val layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
58 | librariesView.layoutManager = layoutManager
59 |
60 | librariesAdapter = LibrariesAdapter(this, null)
61 | librariesView.adapter = librariesAdapter
62 |
63 | viewAuditEventsButton = view.findViewById(R.id.view_audit_events)
64 | viewAuditEventsButton.setOnClickListener {
65 | val intent = Intent(context, AuditEventsActivity::class.java)
66 | startActivity(intent)
67 | }
68 |
69 | startEditingButton = view.findViewById(R.id.start_editing)
70 | startEditingButton.setOnClickListener {
71 | startEditingButton.visibility = View.GONE
72 | newLibraryButton.visibility = View.VISIBLE
73 | finishEditingButton.visibility = View.VISIBLE
74 | databaseManagementFragment.requireView().visibility = View.VISIBLE
75 | clearCacheButton.visibility = View.VISIBLE
76 | isEditingMode = true
77 | }
78 |
79 | finishEditingButton = view.findViewById(R.id.finish_editing)
80 | finishEditingButton.setOnClickListener {
81 | isEditingMode = false
82 | clearCacheButton.visibility = View.GONE
83 | databaseManagementFragment.requireView().visibility = View.GONE
84 | finishEditingButton.visibility = View.GONE
85 | newLibraryButton.visibility = View.GONE
86 | startEditingButton.setVisibility(View.VISIBLE)
87 | }
88 |
89 | newLibraryButton = view.findViewById(R.id.new_library)
90 | newLibraryButton.setOnClickListener { create() }
91 |
92 | clearCacheButton = view.findViewById(R.id.clear_cache)
93 | clearCacheButton.setOnClickListener { clearCache() }
94 |
95 | touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
96 | override fun onMove(
97 | recyclerView: RecyclerView,
98 | viewHolder: RecyclerView.ViewHolder,
99 | target: RecyclerView.ViewHolder
100 | ): Boolean {
101 | val holderA = viewHolder as LibrariesAdapter.ViewHolder
102 | val holderB = target as LibrariesAdapter.ViewHolder
103 |
104 | swap(holderA.libraryId, holderB.libraryId)
105 | librariesAdapter.notifyItemMoved(holderA.bindingAdapterPosition, holderB.bindingAdapterPosition)
106 |
107 | return true
108 | }
109 |
110 | override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
111 | // no-op
112 | }
113 |
114 | override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
115 | return makeMovementFlags(ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0)
116 | }
117 |
118 | override fun isLongPressDragEnabled(): Boolean {
119 | return false
120 | }
121 |
122 | override fun isItemViewSwipeEnabled(): Boolean {
123 | return false
124 | }
125 | })
126 | touchHelper.attachToRecyclerView(librariesView)
127 |
128 | updateCursor()
129 | }
130 |
131 | override fun onDetach() {
132 | super.onDetach()
133 | listener = null
134 | }
135 |
136 | override fun onLibraryEditFinished(library: Library?) {
137 | updateCursor()
138 | }
139 |
140 | internal fun startDrag(holder: LibrariesAdapter.ViewHolder?) {
141 | touchHelper.startDrag(holder!!)
142 | }
143 |
144 | fun setCurrentLibraryId(libraryId: Long) {
145 | librariesAdapter.setCurrentLibraryId(libraryId)
146 | }
147 |
148 | fun show(libraryId: Long) {
149 | listener!!.onLibrarySelected(libraryId)
150 | }
151 |
152 | fun edit(libraryId: Long) {
153 | val childFragment = LibraryEditFragment.newInstance(libraryId)
154 | childFragment.show(childFragmentManager, null)
155 | }
156 |
157 | private fun create() {
158 | val library = Library()
159 | library.name = "New Library"
160 | library.position = findHighestPosition(requireContext()) + 1
161 | library.root = "http://example.com/"
162 | library.save(requireContext())
163 | updateCursor()
164 | }
165 |
166 | fun swap(libraryAId: Long, libraryBId: Long) {
167 | val libraryA = findById(requireContext(), libraryAId)
168 | val libraryB = findById(requireContext(), libraryBId)
169 | val aPosition = libraryA!!.position
170 | libraryA.position = libraryB!!.position
171 | libraryB.position = aPosition
172 | libraryA.save(requireContext())
173 | libraryB.save(requireContext())
174 |
175 | updateCursor()
176 | }
177 |
178 | private fun updateCursor() {
179 | val db = DatabaseHelper.instance(requireContext())
180 | val result = db.rawQuery("SELECT * FROM library_roots ORDER BY position ASC", arrayOf())
181 | result.moveToFirst()
182 | librariesAdapter.swapCursor(result)
183 | }
184 |
185 | private fun clearCache() {
186 | try {
187 | lifecycleScope.launch(Dispatchers.IO) {
188 | requireContext().cacheDir.deleteRecursively()
189 | requireContext().externalCacheDirs.filterNotNull().forEach { it.deleteRecursively() }
190 | }
191 |
192 | Toast.makeText(requireContext(), "Cache Cleared", Toast.LENGTH_LONG).show()
193 | }
194 | catch(e: Exception) {
195 | e.printStackTrace()
196 | Toast.makeText(requireContext(), "Error", Toast.LENGTH_LONG).show()
197 | }
198 | }
199 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/BooksAdapter.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import com.bumptech.glide.RequestManager
4 | import com.bumptech.glide.util.ViewPreloadSizeProvider
5 | import com.bumptech.glide.load.model.GlideUrl
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.bumptech.glide.ListPreloader.PreloadModelProvider
8 | import android.widget.TextView
9 | import android.content.Intent
10 | import android.view.LayoutInflater
11 | import android.widget.ImageButton
12 | import net.bloople.manga.audit.AuditEventsActivity
13 | import android.widget.PopupWindow
14 | import android.view.ViewGroup
15 | import android.view.Gravity
16 | import com.bumptech.glide.RequestBuilder
17 | import android.graphics.drawable.Drawable
18 | import android.view.View
19 | import android.widget.ImageView
20 | import java.util.ArrayList
21 |
22 | internal class BooksAdapter(requestManager: RequestManager, preloadSizeProvider: ViewPreloadSizeProvider) :
23 | RecyclerView.Adapter(), PreloadModelProvider {
24 | private val requestManager: RequestManager
25 | private val preloadSizeProvider: ViewPreloadSizeProvider
26 | private var books = ArrayList()
27 | private var booksMetadata = HashMap();
28 | private var selectedBookIds = ArrayList()
29 | private var selectable = false
30 |
31 | init {
32 | setHasStableIds(true)
33 | this.requestManager = requestManager
34 | this.preloadSizeProvider = preloadSizeProvider
35 | }
36 |
37 | override fun getItemId(position: Int): Long {
38 | return books[position].id
39 | }
40 |
41 | fun isSelectable(): Boolean {
42 | return selectable
43 | }
44 |
45 | fun setSelectable(isSelectable: Boolean) {
46 | selectable = isSelectable
47 | notifyDataSetChanged()
48 | }
49 |
50 | fun getSelectedBookIds(): ArrayList {
51 | return selectedBookIds
52 | }
53 |
54 | fun setSelectedBookIds(selectedBookIds: ArrayList) {
55 | this.selectedBookIds = selectedBookIds
56 | notifyDataSetChanged()
57 | }
58 |
59 | fun clearSelectedBookIds() {
60 | setSelectedBookIds(ArrayList())
61 | }
62 |
63 | // Return the size of your dataset (invoked by the layout manager)
64 | override fun getItemCount(): Int {
65 | return books.size
66 | }
67 |
68 | fun update(searchResults: SearchResults) {
69 | books = searchResults.books
70 | booksMetadata = searchResults.booksMetadata
71 | notifyDataSetChanged()
72 | }
73 |
74 | internal inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
75 | var pageCountView: TextView = view.findViewById(R.id.page_count_view)
76 | var openedCountView: TextView = view.findViewById(R.id.opened_count_view)
77 | var textView: TextView = view.findViewById(R.id.text_view)
78 | var imageView: ImageView = view.findViewById(R.id.image_view)
79 | var selectableView: ImageView = view.findViewById(R.id.selectable)
80 |
81 | init {
82 | view.setOnClickListener { v: View ->
83 | val book = books[bindingAdapterPosition]
84 | val bookId = book.id
85 |
86 | if(selectable) {
87 | if(selectedBookIds.contains(bookId)) {
88 | selectedBookIds.remove(bookId)
89 | v.isActivated = false
90 | }
91 | else {
92 | selectedBookIds.add(bookId)
93 | v.isActivated = true
94 | }
95 |
96 | notifyItemChanged(bindingAdapterPosition)
97 | }
98 | else {
99 | openBook(book, true)
100 | }
101 | }
102 |
103 | view.setOnLongClickListener {
104 | if(selectable) return@setOnLongClickListener false
105 |
106 | val book = books[bindingAdapterPosition]
107 | openBook(book, false)
108 |
109 | true
110 | }
111 |
112 | textView.setOnClickListener {
113 | val book = books[bindingAdapterPosition]
114 | showFullBookTitle(book)
115 | }
116 |
117 | textView.setOnLongClickListener {
118 | val book = books[bindingAdapterPosition]
119 | showBookTags(book)
120 |
121 | true
122 | }
123 | }
124 |
125 | private fun openBook(book: Book, resume: Boolean) {
126 | val intent = Intent(itemView.context, ReadingActivity::class.java)
127 | intent.putExtra("_id", book.id)
128 | intent.putExtra("resume", resume)
129 | intent.putExtra("libraryId", book.library.id)
130 |
131 | itemView.context.startActivity(intent)
132 | }
133 |
134 | private fun showFullBookTitle(book: Book) {
135 | val popupView = LayoutInflater.from(itemView.context).inflate(R.layout.index_book_title_popup, null, false)
136 | val bookTitleView: TextView = popupView.findViewById(R.id.book_title)
137 | bookTitleView.text = book.title
138 |
139 | val viewAuditEventsButton: ImageButton = popupView.findViewById(R.id.view_audit_events)
140 | viewAuditEventsButton.setOnClickListener {
141 | val intent = Intent(viewAuditEventsButton.context, AuditEventsActivity::class.java)
142 | intent.putExtra("resourceId", book.id)
143 |
144 | viewAuditEventsButton.context.startActivity(intent)
145 | }
146 |
147 | val popupWidth = textView.width + bookTitleView.paddingStart + bookTitleView.paddingEnd
148 | val popup = PopupWindow(popupView, popupWidth, ViewGroup.LayoutParams.WRAP_CONTENT)
149 | popup.isFocusable = true
150 | popup.isOutsideTouchable = true
151 | popup.elevation = 24f
152 |
153 | popupView.setOnClickListener { popup.dismiss() }
154 |
155 | popup.showAsDropDown(textView, -bookTitleView.paddingStart, -textView.height, Gravity.TOP or Gravity.START)
156 | }
157 |
158 | private fun showBookTags(book: Book) {
159 | val indexActivity = itemView.context as IndexActivity
160 |
161 | val tagChooser = TagChooserFragment.newInstance(book.tags.toTypedArray())
162 | tagChooser.show(indexActivity.supportFragmentManager, "tag_chooser")
163 | }
164 | }
165 |
166 | // Create new views (invoked by the layout manager)
167 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
168 | val view = LayoutInflater.from(parent.context).inflate(R.layout.index_book_view, parent, false)
169 |
170 | val viewWidthToBitmapWidthRatio = parent.width.toDouble() / 4.0 / 197.0
171 | view.layoutParams.height = (310.0 * viewWidthToBitmapWidthRatio).toInt()
172 |
173 | preloadSizeProvider.setView(view)
174 |
175 | return ViewHolder(view)
176 | }
177 |
178 | // Replace the contents of a view (invoked by the layout manager)
179 | override fun onBindViewHolder(holder: ViewHolder, position: Int) {
180 | val book = books[position]
181 | val metadata = booksMetadata[book.id]
182 |
183 | holder.selectableView.visibility = if(selectable) View.VISIBLE else View.GONE
184 | if(selectable) holder.itemView.isActivated = selectedBookIds.contains(book.id)
185 |
186 | //holder.textView.setText(title.substring(0, Math.min(50, title.length())));
187 | holder.textView.text = book.title
188 |
189 | holder.pageCountView.text = String.format("%,d", book.pages)
190 |
191 | val openedCount = metadata?.openedCount ?: 0
192 | if(openedCount > 0) {
193 | holder.openedCountView.visibility = View.VISIBLE
194 | holder.openedCountView.text = String.format("%,d", openedCount)
195 | }
196 | else {
197 | holder.openedCountView.visibility = View.GONE
198 | }
199 |
200 | requestManager
201 | .load(book.thumbnailUrl.toGlideUrl())
202 | .into(holder.imageView)
203 | }
204 |
205 | override fun getPreloadItems(position: Int): List {
206 | val book = books[position]
207 | return listOf(book.thumbnailUrl.toGlideUrl())
208 | }
209 |
210 | override fun getPreloadRequestBuilder(url: GlideUrl): RequestBuilder {
211 | return requestManager.load(url)
212 | }
213 | }
--------------------------------------------------------------------------------
/app/src/main/java/net/bloople/manga/IndexActivity.kt:
--------------------------------------------------------------------------------
1 | package net.bloople.manga
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.AlertDialog
5 | import androidx.appcompat.app.AppCompatActivity
6 | import net.bloople.manga.LibrariesFragment.OnLibrarySelectedListener
7 | import net.bloople.manga.audit.LibrariesAuditor
8 | import androidx.recyclerview.widget.GridLayoutManager
9 | import android.widget.AutoCompleteTextView
10 | import android.widget.TextView
11 | import android.os.Bundle
12 | import androidx.lifecycle.ViewModelProvider
13 | import android.view.inputmethod.EditorInfo
14 | import android.view.MotionEvent
15 | import androidx.recyclerview.widget.RecyclerView
16 | import com.bumptech.glide.Glide
17 | import com.bumptech.glide.util.ViewPreloadSizeProvider
18 | import com.bumptech.glide.load.model.GlideUrl
19 | import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
20 | import android.content.DialogInterface
21 | import android.view.KeyEvent
22 | import android.view.Menu
23 | import android.view.MenuItem
24 | import android.view.View
25 | import android.view.inputmethod.InputMethodManager
26 | import androidx.activity.enableEdgeToEdge
27 | import androidx.appcompat.widget.Toolbar
28 | import androidx.lifecycle.lifecycleScope
29 | import com.bumptech.glide.load.engine.DiskCacheStrategy
30 | import com.bumptech.glide.request.RequestOptions
31 | import kotlinx.coroutines.launch
32 |
33 | class IndexActivity : AppCompatActivity(), OnLibrarySelectedListener {
34 | private lateinit var model: IndexViewModel
35 | private lateinit var librariesFragment: LibrariesFragment
36 | private lateinit var auditor: LibrariesAuditor
37 | private lateinit var adapter: BooksAdapter
38 | private lateinit var booksLayoutManager: GridLayoutManager
39 | private lateinit var searchField: AutoCompleteTextView
40 | private lateinit var searchResultsToolbar: TextView
41 |
42 | private lateinit var queryService: QueryService
43 |
44 | @SuppressLint("ClickableViewAccessibility")
45 | override fun onCreate(savedInstanceState: Bundle?) {
46 | enableEdgeToEdge()
47 | super.onCreate(savedInstanceState)
48 |
49 | setContentView(R.layout.activity_index)
50 |
51 | model = ViewModelProvider(this)[IndexViewModel::class.java]
52 |
53 | librariesFragment = supportFragmentManager.findFragmentById(R.id.libraries_fragment) as LibrariesFragment
54 |
55 | auditor = LibrariesAuditor(applicationContext)
56 |
57 | val toolbar = findViewById(R.id.toolbar)
58 | setSupportActionBar(toolbar)
59 |
60 | searchResultsToolbar = findViewById(R.id.search_results_toolbar)
61 |
62 | model.sorterDescription.observe(this) { description: String? -> searchResultsToolbar.text = description }
63 |
64 | searchField = findViewById(R.id.search_field)
65 |
66 | searchField.setOnEditorActionListener { _: TextView?, actionId: Int, event: KeyEvent? ->
67 | var handled = false
68 | if(actionId == EditorInfo.IME_ACTION_SEARCH || event?.keyCode == KeyEvent.KEYCODE_ENTER) {
69 | val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
70 | imm.hideSoftInputFromWindow(searchField.windowToken, 0)
71 | searchField.clearFocus()
72 |
73 | onSearch(searchField.text.toString())
74 |
75 | handled = true
76 | }
77 | handled
78 | }
79 |
80 | searchField.setOnTouchListener { _: View?, event: MotionEvent ->
81 | val DRAWABLE_RIGHT = 2
82 |
83 | if(event.action == MotionEvent.ACTION_UP) {
84 | val clickIndex = searchField.right -
85 | searchField.compoundDrawables[DRAWABLE_RIGHT].bounds.width()
86 |
87 | if(event.rawX >= clickIndex) {
88 | searchField.setText("")
89 | onSearch("")
90 |
91 | return@setOnTouchListener true
92 | }
93 | }
94 | false
95 | }
96 |
97 | val booksView = findViewById(R.id.books_view)
98 | booksLayoutManager = GridLayoutManager(this, 4)
99 | booksView.layoutManager = booksLayoutManager
100 |
101 | val requestManager = Glide.with(this).applyDefaultRequestOptions(
102 | RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE))
103 | val sizeProvider = ViewPreloadSizeProvider()
104 |
105 | adapter = BooksAdapter(requestManager, sizeProvider)
106 | booksView.adapter = adapter
107 |
108 | val preloader = RecyclerViewPreloader(
109 | requestManager, adapter, sizeProvider, 12
110 | )
111 |
112 | booksView.addOnScrollListener(preloader)
113 |
114 | val collections = CollectionsManager(this, adapter)
115 | collections.setup()
116 |
117 | queryService = QueryService(this, searchField)
118 |
119 | model.searchResults.observe(this) { searchResults: SearchResults ->
120 | adapter.update(searchResults)
121 | }
122 |
123 | val intent = intent
124 | val intentLibraryId = intent.getLongExtra("libraryId", -1)
125 |
126 | if(intentLibraryId != -1L) loadLibrary(intentLibraryId)
127 | else if(savedInstanceState == null) loadLibrary(-1L)
128 | }
129 |
130 | override fun onRestoreInstanceState(savedInstanceState: Bundle) {
131 | super.onRestoreInstanceState(savedInstanceState)
132 | val sortMethod = savedInstanceState.getString("sortMethod")
133 | val sortDirectionAsc = savedInstanceState.getBoolean("sortDirectionAsc")
134 | if(sortMethod != null) model.setSort(BooksSortMethod.valueOf(sortMethod), sortDirectionAsc)
135 | loadLibrary(savedInstanceState.getLong("libraryId"))
136 | }
137 |
138 | public override fun onSaveInstanceState(savedInstanceState: Bundle) {
139 | val library = model.getLibrary()
140 | if(library != null) savedInstanceState.putLong("libraryId", library.id)
141 | savedInstanceState.putString("sortMethod", model.sortMethod.toString())
142 | savedInstanceState.putBoolean("sortDirectionAsc", model.sortDirectionAsc)
143 | super.onSaveInstanceState(savedInstanceState)
144 | }
145 |
146 | @Suppress("OVERRIDE_DEPRECATION")
147 | override fun onBackPressed() {
148 | super.onBackPressed()
149 | AlertDialog.Builder(this)
150 | .setMessage("Are you sure you want to exit?")
151 | .setCancelable(false)
152 | .setPositiveButton("Yes") { _: DialogInterface?, _: Int -> finish() }
153 | .setNegativeButton("No", null)
154 | .show()
155 | }
156 |
157 | override fun onCreateOptionsMenu(menu: Menu): Boolean {
158 | super.onCreateOptionsMenu(menu)
159 |
160 | val inflater = menuInflater
161 | inflater.inflate(R.menu.list_menu, menu)
162 |
163 | return true
164 | }
165 |
166 | override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
167 | val sortMethod = model.sortMethod
168 | var newSortMethod = sortMethod
169 |
170 | when(menuItem.itemId) {
171 | R.id.sort_alphabetic -> {
172 | newSortMethod = BooksSortMethod.SORT_ALPHABETIC
173 | }
174 | R.id.sort_age -> {
175 | newSortMethod = BooksSortMethod.SORT_AGE
176 | }
177 | R.id.sort_size -> {
178 | newSortMethod = BooksSortMethod.SORT_LENGTH
179 | }
180 | R.id.sort_last_opened -> {
181 | newSortMethod = BooksSortMethod.SORT_LAST_OPENED
182 | }
183 | R.id.sort_opened_count -> {
184 | newSortMethod = BooksSortMethod.SORT_OPENED_COUNT
185 | }
186 | R.id.sort_random -> {
187 | newSortMethod = BooksSortMethod.SORT_RANDOM
188 | }
189 | }
190 |
191 | var sortDirectionAsc = model.sortDirectionAsc
192 | if(sortMethod == newSortMethod) sortDirectionAsc = !sortDirectionAsc
193 | model.setSort(newSortMethod, sortDirectionAsc)
194 | scrollToTop()
195 |
196 | return true
197 | }
198 |
199 | private fun loadLibrary(libraryId: Long) {
200 | lifecycleScope.launch {
201 | val library = LibraryService.ensureLibrary(this@IndexActivity, libraryId) ?: return@launch
202 |
203 | librariesFragment.setCurrentLibraryId(library.id)
204 | auditor.selected(library)
205 | model.setLibrary(library)
206 | }
207 | }
208 |
209 | private fun scrollToTop() {
210 | booksLayoutManager.scrollToPositionWithOffset(0, 0)
211 | }
212 |
213 | private fun onSearch(text: String) {
214 | model.setSearchText(text)
215 | scrollToTop()
216 | queryService.onSearch(text)
217 | }
218 |
219 | fun useList(list: BookList?) {
220 | model.useList(list)
221 | scrollToTop()
222 | }
223 |
224 | override fun onLibrarySelected(libraryId: Long) {
225 | loadLibrary(libraryId)
226 | scrollToTop()
227 | }
228 |
229 | fun useTag(tag: String) {
230 | val text = "\"" + tag + "\""
231 | searchField.setText(text)
232 | onSearch(text)
233 | }
234 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90 | ' "$PWD" ) || exit
91 |
92 | # Use the maximum available, or set MAX_FD != -1 to use that value.
93 | MAX_FD=maximum
94 |
95 | warn () {
96 | echo "$*"
97 | } >&2
98 |
99 | die () {
100 | echo
101 | echo "$*"
102 | echo
103 | exit 1
104 | } >&2
105 |
106 | # OS specific support (must be 'true' or 'false').
107 | cygwin=false
108 | msys=false
109 | darwin=false
110 | nonstop=false
111 | case "$( uname )" in #(
112 | CYGWIN* ) cygwin=true ;; #(
113 | Darwin* ) darwin=true ;; #(
114 | MSYS* | MINGW* ) msys=true ;; #(
115 | NONSTOP* ) nonstop=true ;;
116 | esac
117 |
118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
119 |
120 |
121 | # Determine the Java command to use to start the JVM.
122 | if [ -n "$JAVA_HOME" ] ; then
123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
124 | # IBM's JDK on AIX uses strange locations for the executables
125 | JAVACMD=$JAVA_HOME/jre/sh/java
126 | else
127 | JAVACMD=$JAVA_HOME/bin/java
128 | fi
129 | if [ ! -x "$JAVACMD" ] ; then
130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
131 |
132 | Please set the JAVA_HOME variable in your environment to match the
133 | location of your Java installation."
134 | fi
135 | else
136 | JAVACMD=java
137 | if ! command -v java >/dev/null 2>&1
138 | then
139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
140 |
141 | Please set the JAVA_HOME variable in your environment to match the
142 | location of your Java installation."
143 | fi
144 | fi
145 |
146 | # Increase the maximum file descriptors if we can.
147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
148 | case $MAX_FD in #(
149 | max*)
150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
151 | # shellcheck disable=SC2039,SC3045
152 | MAX_FD=$( ulimit -H -n ) ||
153 | warn "Could not query maximum file descriptor limit"
154 | esac
155 | case $MAX_FD in #(
156 | '' | soft) :;; #(
157 | *)
158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
159 | # shellcheck disable=SC2039,SC3045
160 | ulimit -n "$MAX_FD" ||
161 | warn "Could not set maximum file descriptor limit to $MAX_FD"
162 | esac
163 | fi
164 |
165 | # Collect all arguments for the java command, stacking in reverse order:
166 | # * args from the command line
167 | # * the main class name
168 | # * -classpath
169 | # * -D...appname settings
170 | # * --module-path (only if needed)
171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
172 |
173 | # For Cygwin or MSYS, switch paths to Windows format before running java
174 | if "$cygwin" || "$msys" ; then
175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
177 |
178 | JAVACMD=$( cygpath --unix "$JAVACMD" )
179 |
180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
181 | for arg do
182 | if
183 | case $arg in #(
184 | -*) false ;; # don't mess with options #(
185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
186 | [ -e "$t" ] ;; #(
187 | *) false ;;
188 | esac
189 | then
190 | arg=$( cygpath --path --ignore --mixed "$arg" )
191 | fi
192 | # Roll the args list around exactly as many times as the number of
193 | # args, so each arg winds up back in the position where it started, but
194 | # possibly modified.
195 | #
196 | # NB: a `for` loop captures its iteration list before it begins, so
197 | # changing the positional parameters here affects neither the number of
198 | # iterations, nor the values presented in `arg`.
199 | shift # remove old arg
200 | set -- "$@" "$arg" # push replacement arg
201 | done
202 | fi
203 |
204 |
205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
207 |
208 | # Collect all arguments for the java command:
209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
210 | # and any embedded shellness will be escaped.
211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
212 | # treated as '${Hostname}' itself on the command line.
213 |
214 | set -- \
215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
216 | -classpath "$CLASSPATH" \
217 | org.gradle.wrapper.GradleWrapperMain \
218 | "$@"
219 |
220 | # Stop when "xargs" is not available.
221 | if ! command -v xargs >/dev/null 2>&1
222 | then
223 | die "xargs is not available"
224 | fi
225 |
226 | # Use "xargs" to parse quoted args.
227 | #
228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
229 | #
230 | # In Bash we could simply go:
231 | #
232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
233 | # set -- "${ARGS[@]}" "$@"
234 | #
235 | # but POSIX shell has neither arrays nor command substitution, so instead we
236 | # post-process each arg (as a line of input to sed) to backslash-escape any
237 | # character that might be a shell metacharacter, then use eval to reverse
238 | # that process (while maintaining the separation between arguments), and wrap
239 | # the whole thing up as a single "set" statement.
240 | #
241 | # This will of course break if any of these variables contains a newline or
242 | # an unmatched quote.
243 | #
244 |
245 | eval "set -- $(
246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
247 | xargs -n1 |
248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
249 | tr '\n' ' '
250 | )" '"$@"'
251 |
252 | exec "$JAVACMD" "$@"
253 |
--------------------------------------------------------------------------------