├── 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 | 3 | 7 | 11 | 16 | 21 | 26 | 31 | -------------------------------------------------------------------------------- /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 |