From 63e3d70016675bbf3b2d2dea1acb47c32824dc7c Mon Sep 17 00:00:00 2001 From: Jacob Spizziri Date: Thu, 17 Mar 2022 10:18:21 -0500 Subject: [PATCH] feat(android): implement a basic ebook reader view for android --- android/build.gradle | 76 ++- android/gradle.properties | 4 +- .../com/reactnativereadium/ReadiumPackage.kt | 2 +- .../com/reactnativereadium/ReadiumView.kt | 84 +++ .../reactnativereadium/ReadiumViewManager.kt | 80 ++- .../reactnativereadium/epub/UserSettings.kt | 236 +++++++ .../reader/BaseReaderFragment.kt | 81 +++ .../reader/EpubReaderFragment.kt | 375 ++++++++++++ .../reader/ImageReaderFragment.kt | 68 +++ .../reader/PdfReaderFragment.kt | 82 +++ .../reader/ReaderService.kt | 116 ++++ .../reader/ReaderViewModel.kt | 120 ++++ .../reader/VisualReaderFragment.kt | 78 +++ .../search/SearchFragment.kt | 100 +++ .../search/SearchPagingSource.kt | 44 ++ .../search/SearchResultAdapter.kt | 68 +++ .../utils/ContentResolverUtil.kt | 156 +++++ .../reactnativereadium/utils/Dimensions.kt | 6 + .../reactnativereadium/utils/EventChannel.kt | 61 ++ .../utils/FragmentFactory.kt | 46 ++ .../utils/R2DispatcherActivity.kt | 45 ++ .../utils/SectionDecoration.kt | 98 +++ .../utils/SingleClickListener.kt | 32 + .../utils/SystemUiManagement.kt | 63 ++ .../utils/extensions/Bitmap.kt | 23 + .../utils/extensions/Context.kt | 16 + .../utils/extensions/File.kt | 22 + .../utils/extensions/InputStream.kt | 23 + .../utils/extensions/Link.kt | 6 + .../utils/extensions/Metadata.kt | 6 + .../utils/extensions/URL.kt | 29 + .../utils/extensions/Uri.kt | 17 + .../res/drawable/background_action_mode.xml | 6 + android/src/main/res/drawable/cnl.png | Bin 0 -> 20299 bytes android/src/main/res/drawable/cover.png | Bin 0 -> 8020 bytes .../main/res/drawable/ic_add_white_24dp.xml | 9 + .../drawable/ic_baseline_arrow_forward_24.xml | 5 + .../res/drawable/ic_baseline_bookmark_24.xml | 10 + .../res/drawable/ic_baseline_delete_24.xml | 10 + .../main/res/drawable/ic_baseline_edit_24.xml | 10 + .../ic_baseline_enhanced_encryption_24.xml | 10 + .../drawable/ic_baseline_fast_forward_24.xml | 10 + .../drawable/ic_baseline_fast_rewind_24.xml | 10 + .../drawable/ic_baseline_headphones_24.xml | 10 + .../res/drawable/ic_baseline_pause_24.xml | 10 + .../drawable/ic_baseline_play_arrow_24.xml | 10 + .../res/drawable/ic_baseline_search_24.xml | 10 + .../res/drawable/ic_baseline_skip_next_24.xml | 10 + .../drawable/ic_baseline_skip_previous_24.xml | 10 + .../res/drawable/ic_dashboard_black_24dp.xml | 9 + .../main/res/drawable/ic_fastforward_30.xml | 7 + .../main/res/drawable/ic_info_black_24dp.xml | 9 + .../drawable/ic_local_library_black_24dp.xml | 9 + android/src/main/res/drawable/ic_notch.xml | 4 + .../main/res/drawable/ic_outline_add_24.xml | 10 + .../ic_outline_format_align_justify_24.xml | 10 + .../ic_outline_format_align_left_24.xml | 11 + .../drawable/ic_outline_format_size_24.xml | 10 + .../res/drawable/ic_outline_light_mode_24.xml | 10 + .../main/res/drawable/ic_outline_menu_24.xml | 10 + .../res/drawable/ic_outline_remove_24.xml | 10 + .../res/drawable/ic_outline_wb_sunny_24.xml | 10 + .../src/main/res/drawable/ic_rewind_30.xml | 7 + .../main/res/drawable/icon_font_decrease.png | Bin 0 -> 676 bytes .../main/res/drawable/icon_font_increase.png | Bin 0 -> 599 bytes .../src/main/res/drawable/icon_overflow.png | Bin 0 -> 390 bytes .../src/main/res/drawable/rbtn_selector.xml | 25 + .../res/drawable/rbtn_textcolor_selector.xml | 16 + android/src/main/res/drawable/repfr.png | Bin 0 -> 22697 bytes .../src/main/res/drawable/selector_blue.xml | 42 ++ .../src/main/res/drawable/selector_green.xml | 42 ++ .../src/main/res/drawable/selector_purple.xml | 42 ++ .../src/main/res/drawable/selector_red.xml | 42 ++ .../src/main/res/drawable/selector_yellow.xml | 41 ++ android/src/main/res/layout/activity_epub.xml | 23 + android/src/main/res/layout/activity_main.xml | 32 + .../src/main/res/layout/activity_reader.xml | 6 + .../main/res/layout/add_catalog_dialog.xml | 25 + android/src/main/res/layout/filter_row.xml | 34 ++ android/src/main/res/layout/filter_window.xml | 24 + .../src/main/res/layout/fragment_about.xml | 150 +++++ .../main/res/layout/fragment_audiobook.xml | 151 +++++ .../main/res/layout/fragment_bookshelf.xml | 35 ++ .../src/main/res/layout/fragment_catalog.xml | 56 ++ .../res/layout/fragment_catalog_feed_list.xml | 27 + .../res/layout/fragment_drm_management.xml | 284 +++++++++ .../src/main/res/layout/fragment_listview.xml | 24 + .../src/main/res/layout/fragment_outline.xml | 31 + .../layout/fragment_publication_detail.xml | 55 ++ .../src/main/res/layout/fragment_reader.xml | 6 + .../res/layout/fragment_screen_reader.xml | 143 +++++ .../src/main/res/layout/fragment_search.xml | 39 ++ .../src/main/res/layout/item_group_view.xml | 22 + .../src/main/res/layout/item_recycle_book.xml | 55 ++ .../main/res/layout/item_recycle_bookmark.xml | 72 +++ .../main/res/layout/item_recycle_button.xml | 23 + .../main/res/layout/item_recycle_catalog.xml | 56 ++ .../res/layout/item_recycle_highlight.xml | 85 +++ .../res/layout/item_recycle_horizontal.xml | 45 ++ .../res/layout/item_recycle_navigation.xml | 34 ++ .../main/res/layout/item_recycle_search.xml | 14 + .../src/main/res/layout/item_spinner_days.xml | 19 + android/src/main/res/layout/my_fragment.xml | 13 + android/src/main/res/layout/popup_delete.xml | 29 + android/src/main/res/layout/popup_note.xml | 105 ++++ .../src/main/res/layout/popup_passphrase.xml | 126 ++++ .../res/layout/popup_window_user_settings.xml | 576 ++++++++++++++++++ .../src/main/res/layout/section_header.xml | 25 + .../src/main/res/layout/view_action_mode.xml | 100 +++ .../res/layout/view_action_mode_reverse.xml | 99 +++ android/src/main/res/menu/bottom_nav_menu.xml | 19 + .../src/main/res/menu/menu_action_mode.xml | 26 + android/src/main/res/menu/menu_bookmark.xml | 18 + android/src/main/res/menu/menu_epub.xml | 41 ++ android/src/main/res/menu/menu_filter.xml | 21 + android/src/main/res/menu/menu_reader.xml | 33 + .../src/main/res/navigation/navigation.xml | 46 ++ android/src/main/res/values/arrays.xml | 18 + android/src/main/res/values/colors.xml | 22 + android/src/main/res/values/refs.xml | 5 + android/src/main/res/values/strings.xml | 200 ++++++ android/src/main/res/values/styles.xml | 46 ++ .../main/res/xml/network_security_config.xml | 38 ++ example/android/app/build.gradle | 1 + .../reactnativereadium/MainApplication.java | 1 + example/android/build.gradle | 6 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 2 + example/ios/Podfile | 1 + example/ios/Podfile.lock | 525 +++++++++------- .../ReadiumExample.xcodeproj/project.pbxproj | 41 ++ example/ios/ReadiumExample/Info.plist | 4 +- example/package.json | 10 +- example/src/App.tsx | 138 +---- example/src/Reader.tsx | 134 ++++ example/yarn.lock | 402 ++++++++---- ios/Reader/Common/ReaderViewController.swift | 6 +- src/index.tsx | 34 +- src/interfaces/Dimensions.ts | 4 + src/interfaces/Settings.ts | 22 +- src/interfaces/index.ts | 1 + 141 files changed, 6754 insertions(+), 535 deletions(-) create mode 100644 android/src/main/java/com/reactnativereadium/ReadiumView.kt create mode 100644 android/src/main/java/com/reactnativereadium/epub/UserSettings.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/BaseReaderFragment.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/EpubReaderFragment.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/ImageReaderFragment.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/PdfReaderFragment.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/ReaderService.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/ReaderViewModel.kt create mode 100644 android/src/main/java/com/reactnativereadium/reader/VisualReaderFragment.kt create mode 100644 android/src/main/java/com/reactnativereadium/search/SearchFragment.kt create mode 100644 android/src/main/java/com/reactnativereadium/search/SearchPagingSource.kt create mode 100644 android/src/main/java/com/reactnativereadium/search/SearchResultAdapter.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/ContentResolverUtil.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/Dimensions.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/EventChannel.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/FragmentFactory.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/R2DispatcherActivity.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/SectionDecoration.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/SingleClickListener.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/SystemUiManagement.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/Bitmap.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/Context.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/File.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/InputStream.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/Link.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/Metadata.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/URL.kt create mode 100644 android/src/main/java/com/reactnativereadium/utils/extensions/Uri.kt create mode 100644 android/src/main/res/drawable/background_action_mode.xml create mode 100644 android/src/main/res/drawable/cnl.png create mode 100644 android/src/main/res/drawable/cover.png create mode 100644 android/src/main/res/drawable/ic_add_white_24dp.xml create mode 100644 android/src/main/res/drawable/ic_baseline_arrow_forward_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_bookmark_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_delete_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_edit_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_enhanced_encryption_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_fast_forward_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_fast_rewind_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_headphones_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_pause_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_play_arrow_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_search_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_skip_next_24.xml create mode 100644 android/src/main/res/drawable/ic_baseline_skip_previous_24.xml create mode 100644 android/src/main/res/drawable/ic_dashboard_black_24dp.xml create mode 100644 android/src/main/res/drawable/ic_fastforward_30.xml create mode 100644 android/src/main/res/drawable/ic_info_black_24dp.xml create mode 100644 android/src/main/res/drawable/ic_local_library_black_24dp.xml create mode 100644 android/src/main/res/drawable/ic_notch.xml create mode 100644 android/src/main/res/drawable/ic_outline_add_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_format_align_justify_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_format_align_left_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_format_size_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_light_mode_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_menu_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_remove_24.xml create mode 100644 android/src/main/res/drawable/ic_outline_wb_sunny_24.xml create mode 100644 android/src/main/res/drawable/ic_rewind_30.xml create mode 100644 android/src/main/res/drawable/icon_font_decrease.png create mode 100644 android/src/main/res/drawable/icon_font_increase.png create mode 100644 android/src/main/res/drawable/icon_overflow.png create mode 100644 android/src/main/res/drawable/rbtn_selector.xml create mode 100644 android/src/main/res/drawable/rbtn_textcolor_selector.xml create mode 100644 android/src/main/res/drawable/repfr.png create mode 100644 android/src/main/res/drawable/selector_blue.xml create mode 100644 android/src/main/res/drawable/selector_green.xml create mode 100644 android/src/main/res/drawable/selector_purple.xml create mode 100644 android/src/main/res/drawable/selector_red.xml create mode 100644 android/src/main/res/drawable/selector_yellow.xml create mode 100644 android/src/main/res/layout/activity_epub.xml create mode 100644 android/src/main/res/layout/activity_main.xml create mode 100644 android/src/main/res/layout/activity_reader.xml create mode 100644 android/src/main/res/layout/add_catalog_dialog.xml create mode 100644 android/src/main/res/layout/filter_row.xml create mode 100644 android/src/main/res/layout/filter_window.xml create mode 100644 android/src/main/res/layout/fragment_about.xml create mode 100644 android/src/main/res/layout/fragment_audiobook.xml create mode 100644 android/src/main/res/layout/fragment_bookshelf.xml create mode 100644 android/src/main/res/layout/fragment_catalog.xml create mode 100644 android/src/main/res/layout/fragment_catalog_feed_list.xml create mode 100644 android/src/main/res/layout/fragment_drm_management.xml create mode 100644 android/src/main/res/layout/fragment_listview.xml create mode 100644 android/src/main/res/layout/fragment_outline.xml create mode 100644 android/src/main/res/layout/fragment_publication_detail.xml create mode 100644 android/src/main/res/layout/fragment_reader.xml create mode 100644 android/src/main/res/layout/fragment_screen_reader.xml create mode 100644 android/src/main/res/layout/fragment_search.xml create mode 100644 android/src/main/res/layout/item_group_view.xml create mode 100644 android/src/main/res/layout/item_recycle_book.xml create mode 100644 android/src/main/res/layout/item_recycle_bookmark.xml create mode 100644 android/src/main/res/layout/item_recycle_button.xml create mode 100644 android/src/main/res/layout/item_recycle_catalog.xml create mode 100644 android/src/main/res/layout/item_recycle_highlight.xml create mode 100644 android/src/main/res/layout/item_recycle_horizontal.xml create mode 100644 android/src/main/res/layout/item_recycle_navigation.xml create mode 100644 android/src/main/res/layout/item_recycle_search.xml create mode 100644 android/src/main/res/layout/item_spinner_days.xml create mode 100644 android/src/main/res/layout/my_fragment.xml create mode 100644 android/src/main/res/layout/popup_delete.xml create mode 100644 android/src/main/res/layout/popup_note.xml create mode 100644 android/src/main/res/layout/popup_passphrase.xml create mode 100644 android/src/main/res/layout/popup_window_user_settings.xml create mode 100644 android/src/main/res/layout/section_header.xml create mode 100644 android/src/main/res/layout/view_action_mode.xml create mode 100644 android/src/main/res/layout/view_action_mode_reverse.xml create mode 100644 android/src/main/res/menu/bottom_nav_menu.xml create mode 100644 android/src/main/res/menu/menu_action_mode.xml create mode 100644 android/src/main/res/menu/menu_bookmark.xml create mode 100644 android/src/main/res/menu/menu_epub.xml create mode 100644 android/src/main/res/menu/menu_filter.xml create mode 100644 android/src/main/res/menu/menu_reader.xml create mode 100644 android/src/main/res/navigation/navigation.xml create mode 100644 android/src/main/res/values/arrays.xml create mode 100644 android/src/main/res/values/colors.xml create mode 100644 android/src/main/res/values/refs.xml create mode 100644 android/src/main/res/values/strings.xml create mode 100644 android/src/main/res/values/styles.xml create mode 100644 android/src/main/res/xml/network_security_config.xml create mode 100644 example/src/Reader.tsx create mode 100644 src/interfaces/Dimensions.ts diff --git a/android/build.gradle b/android/build.gradle index 7cc3b94..707ea8f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,7 @@ buildscript { // Buildscript is evaluated before everything else so we can't use getExtOrDefault def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['Readium_kotlinVersion'] + ext.readium_version = '2.1.1' repositories { google() @@ -9,7 +10,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' + classpath 'com.android.tools.build:gradle:4.2.2' // noinspection DifferentKotlinGradleVersion classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } @@ -29,13 +30,12 @@ def getExtOrIntegerDefault(name) { android { compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') defaultConfig { - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') versionCode 1 versionName "1.0" - } - + buildTypes { release { minifyEnabled false @@ -48,9 +48,20 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + viewBinding true + } } repositories { + + maven { url 'https://jitpack.io' } + mavenCentral() jcenter() google() @@ -127,4 +138,61 @@ dependencies { // noinspection GradleDynamicVersion api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // readium deps + implementation "com.github.readium.kotlin-toolkit:readium-shared:$readium_version" + implementation "com.github.readium.kotlin-toolkit:readium-streamer:$readium_version" + implementation "com.github.readium.kotlin-toolkit:readium-navigator:$readium_version" + + // other deps + implementation 'androidx.core:core-ktx:1.6.0' + implementation "androidx.activity:activity-ktx:1.3.1" + implementation "androidx.appcompat:appcompat:1.3.1" + implementation "androidx.browser:browser:1.3.0" + implementation "androidx.cardview:cardview:1.0.0" + implementation "androidx.constraintlayout:constraintlayout:2.1.1" + implementation "androidx.fragment:fragment-ktx:1.3.6" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' + implementation "androidx.paging:paging-runtime-ktx:3.0.1" + implementation "androidx.recyclerview:recyclerview:1.2.1" + implementation "androidx.viewpager2:viewpager2:1.0.0" + implementation "androidx.webkit:webkit:1.4.0" + //noinspection GradleDependency + implementation ("com.github.edrlab.nanohttpd:nanohttpd:master-SNAPSHOT") { + exclude group: 'org.parboiled' + } + //noinspection GradleDependency + implementation ("com.github.edrlab.nanohttpd:nanohttpd-nanolets:master-SNAPSHOT") { + exclude group: 'org.parboiled' + } + implementation "com.google.android.material:material:1.4.0" + implementation "com.jakewharton.timber:timber:4.7.1" + // AM NOTE: needs to stay this version for now (June 24,2020) + //noinspection GradleDependency + implementation "com.squareup.picasso:picasso:2.5.2" + implementation "joda-time:joda-time:2.10.10" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2" + // AM NOTE: needs to stay this version for now (June 24,2020) + //noinspection GradleDependency + implementation 'org.jsoup:jsoup:1.13.1' + + // Room database + final room_version = '2.4.0-beta01' + implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-ktx:$room_version" + annotationProcessor "androidx.room:room-compiler:$room_version" + + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + //noinspection LifecycleAnnotationProcessorWithJava8 + annotationProcessor "androidx.lifecycle:lifecycle-compiler:2.3.1" + + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" + testImplementation "junit:junit:4.13.2" + + androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" + androidTestImplementation "androidx.test:runner:1.4.0" } diff --git a/android/gradle.properties b/android/gradle.properties index 0a4670d..e35b6f9 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -Readium_kotlinVersion=1.3.50 -Readium_compileSdkVersion=29 +Readium_kotlinVersion=1.5.31 +Readium_compileSdkVersion=31 Readium_targetSdkVersion=29 diff --git a/android/src/main/java/com/reactnativereadium/ReadiumPackage.kt b/android/src/main/java/com/reactnativereadium/ReadiumPackage.kt index 738f4de..f529920 100644 --- a/android/src/main/java/com/reactnativereadium/ReadiumPackage.kt +++ b/android/src/main/java/com/reactnativereadium/ReadiumPackage.kt @@ -12,6 +12,6 @@ class ReadiumPackage : ReactPackage { } override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf(ReadiumViewManager()) + return listOf(ReadiumViewManager(reactContext)) } } diff --git a/android/src/main/java/com/reactnativereadium/ReadiumView.kt b/android/src/main/java/com/reactnativereadium/ReadiumView.kt new file mode 100644 index 0000000..01c3bd3 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/ReadiumView.kt @@ -0,0 +1,84 @@ +package com.reactnativereadium + +import android.view.Choreographer +import android.widget.FrameLayout +import androidx.fragment.app.FragmentActivity +import com.facebook.react.bridge.Arguments +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.reactnativereadium.reader.BaseReaderFragment +import com.reactnativereadium.reader.EpubReaderFragment +import com.reactnativereadium.reader.ReaderViewModel +import com.reactnativereadium.utils.Dimensions +import org.readium.r2.shared.extensions.toMap +import org.readium.r2.shared.publication.Locator + +class ReadiumView( + val reactContext: ThemedReactContext +) : FrameLayout(reactContext) { + var dimensions: Dimensions = Dimensions(0,0) + var fragment: BaseReaderFragment? = null + + fun updateLocation(locator: Locator) : Boolean { + if (fragment == null) { + return false + } else { + return this.fragment!!.go(locator, true) + } + } + + fun updateSettingsFromMap(map: Map) { + if (fragment != null && fragment is EpubReaderFragment) { + (fragment as EpubReaderFragment).updateSettingsFromMap(map) + } + } + + fun addFragment(frag: BaseReaderFragment) { + fragment = frag + setupLayout() + val activity: FragmentActivity? = reactContext.currentActivity as FragmentActivity? + activity!!.supportFragmentManager + .beginTransaction() + .replace(this.id, frag, this.id.toString()) + .commit() + + // subscribe to reader events + frag.channel.receive(frag) { event -> + val module = reactContext.getJSModule(RCTEventEmitter::class.java) + when (event) { + is ReaderViewModel.Event.LocatorUpdate -> { + val json = event.locator.toJSON() + val payload = Arguments.makeNativeMap(json.toMap()) + module.receiveEvent( + this.id.toInt(), + ReadiumViewManager.ON_LOCATION_CHANGE, + payload + ) + } + } + } + } + + private fun setupLayout() { + Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + manuallyLayoutChildren() + this@ReadiumView.viewTreeObserver.dispatchOnGlobalLayout() + Choreographer.getInstance().postFrameCallback(this) + } + }) + } + + /** + * Layout all children properly + */ + private fun manuallyLayoutChildren() { + // propWidth and propHeight coming from react-native props + val width = dimensions.width + val height = dimensions.height + this.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)) + this.layout(0, 0, width, height) + } +} diff --git a/android/src/main/java/com/reactnativereadium/ReadiumViewManager.kt b/android/src/main/java/com/reactnativereadium/ReadiumViewManager.kt index 1f544c2..83177ea 100644 --- a/android/src/main/java/com/reactnativereadium/ReadiumViewManager.kt +++ b/android/src/main/java/com/reactnativereadium/ReadiumViewManager.kt @@ -1,20 +1,80 @@ package com.reactnativereadium -import android.graphics.Color -import android.view.View -import com.facebook.react.uimanager.SimpleViewManager -import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.bridge.* +import com.facebook.react.common.MapBuilder import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.annotations.ReactPropGroup +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.reactnativereadium.reader.ReaderService +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.readium.r2.shared.publication.Locator + + +class ReadiumViewManager( + val reactContext: ReactApplicationContext +) : ViewGroupManager() { + private var svc = ReaderService(reactContext) -class ReadiumViewManager : SimpleViewManager() { override fun getName() = "ReadiumView" - override fun createViewInstance(reactContext: ThemedReactContext): View { - return View(reactContext) + override fun createViewInstance(reactContext: ThemedReactContext): ReadiumView { + return ReadiumView(reactContext) + } + + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return MapBuilder.builder().put( + ON_LOCATION_CHANGE, + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", ON_LOCATION_CHANGE) + ) + ).build() + } + + @ReactProp(name = "file") + fun setFile(view: ReadiumView, file: ReadableMap) { + val path = file.getString("url") + val locatorMap = file.getMap("initialLocation") + var initialLocation: Locator? = null + + if (locatorMap != null) { + initialLocation = Locator.fromJSON(JSONObject(locatorMap.toHashMap())) + } + + runBlocking { + svc.openPublication(path!!, initialLocation) { fragment -> + view.addFragment(fragment) + } + } + } + + @ReactProp(name = "location") + fun setLocation(view: ReadiumView, location: ReadableMap) { + val locator = Locator.fromJSON(JSONObject(location.toHashMap())) + + if (locator != null) { + view.updateLocation(locator) + } + } + + @ReactProp(name = "settings") + fun setSettings(view: ReadiumView, settings: ReadableMap) { + view.updateSettingsFromMap(settings.toHashMap()) + } + + @ReactPropGroup(names = ["width", "height"], customType = "Style") + fun setStyle(view: ReadiumView?, index: Int, value: Int) { + if (index == 0) { + view?.dimensions?.width = value + } + if (index == 1) { + view?.dimensions?.height = value + } } - @ReactProp(name = "color") - fun setColor(view: View, color: String) { - view.setBackgroundColor(Color.parseColor(color)) + companion object { + var ON_LOCATION_CHANGE = "onLocationChange" } } diff --git a/android/src/main/java/com/reactnativereadium/epub/UserSettings.kt b/android/src/main/java/com/reactnativereadium/epub/UserSettings.kt new file mode 100644 index 0000000..f6869d2 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/epub/UserSettings.kt @@ -0,0 +1,236 @@ +package com.reactnativereadium.epub + +import android.content.Context +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatActivity +import org.json.JSONArray +import org.readium.r2.navigator.R2BasicWebView +import org.readium.r2.navigator.R2WebView +import org.readium.r2.navigator.epub.fxl.R2FXLLayout +import org.readium.r2.navigator.pager.R2ViewPager +import org.readium.r2.shared.* +import com.reactnativereadium.R +import java.io.File + +class UserSettings( + var preferences: SharedPreferences, + val context: Context, + private val UIPreset: MutableMap +) { + + lateinit var resourcePager: R2ViewPager + + private val appearanceValues = listOf("readium-default-on", "readium-sepia-on", "readium-night-on") + private val fontFamilyValues = listOf("Original", "PT Serif", "Roboto", "Source Sans Pro", "Vollkorn", "OpenDyslexic", "AccessibleDfA", "IA Writer Duospace") + private val textAlignmentValues = listOf("justify", "start") + private val columnCountValues = listOf("auto", "1", "2") + + private var fontSize = 100f + private var fontOverride = false + private var fontFamily = 0 + private var appearance = 0 + private var verticalScroll = false + + //Advanced settings + private var publisherDefaults = false + private var textAlignment = 0 + private var columnCount = 0 + private var wordSpacing = 0f + private var letterSpacing = 0f + private var pageMargins = 2f + private var lineHeight = 1f + + private var userProperties: UserProperties + + init { + appearance = preferences.getInt(APPEARANCE_REF, appearance) + verticalScroll = preferences.getBoolean(SCROLL_REF, verticalScroll) + fontFamily = preferences.getInt(FONT_FAMILY_REF, fontFamily) + if (fontFamily != 0) { + fontOverride = true + } + publisherDefaults = preferences.getBoolean(PUBLISHER_DEFAULT_REF, publisherDefaults) + textAlignment = preferences.getInt(TEXT_ALIGNMENT_REF, textAlignment) + columnCount = preferences.getInt(COLUMN_COUNT_REF, columnCount) + + + fontSize = preferences.getFloat(FONT_SIZE_REF, fontSize) + wordSpacing = preferences.getFloat(WORD_SPACING_REF, wordSpacing) + letterSpacing = preferences.getFloat(LETTER_SPACING_REF, letterSpacing) + pageMargins = preferences.getFloat(PAGE_MARGINS_REF, pageMargins) + lineHeight = preferences.getFloat(LINE_HEIGHT_REF, lineHeight) + userProperties = getUserSettings() + + //Setting up screen brightness + val backLightValue = preferences.getInt("reader_brightness", 50).toFloat() / 100 + val layoutParams = (context as AppCompatActivity).window.attributes + layoutParams.screenBrightness = backLightValue + context.window.attributes = layoutParams + } + + fun updateSettingsFromMap(map: Map) { + userProperties.properties.forEach { property -> + val key = property.ref + val value = map[key] + + val isPropertyModified = when (property) { + is Enumerable -> updateEnumerableFromKeyValue(property, key, value) + is Incremental -> updateIncrementalFromKeyValue(property, key, value) + is Switchable -> updateSwitchableFromKeyValue(property, key, value) + } + + // apply the changes to the view + if (isPropertyModified) { + updateViewCSS(key) + } + } + } + + private fun updateEnumerableFromKeyValue(property: Enumerable, key: String, value: Any?): Boolean { + if (value == null) return false + var update: Int? + + if (value is Int) { + update = value + } else if (value is Float) { + update = value.toInt() + } else if (value is Double) { + update = value.toInt() + } else { + throw Error("Invalid value type '${value.javaClass.simpleName}' passed for setting: $key = $value") + } + + property.index = update + updateEnumerable(property) + return true + } + + private fun updateIncrementalFromKeyValue(property: Incremental, key: String, value: Any?): Boolean { + if (value == null) return false + var update: Float? + + if (value is Int) { + update = value.toFloat() + } else if (value is Float) { + update = value + } else if (value is Double) { + update = value.toFloat() + } else { + throw Error("Invalid value type '${value.javaClass.simpleName}' passed for setting: $key = $value") + } + + property.value = update + updateIncremental(property) + return true + } + + private fun updateSwitchableFromKeyValue(property: Switchable, key: String, value: Any?): Boolean { + if (value == null) return false + var update: Boolean? + + if (value is Boolean) { + update = value + } else { + throw Error("Invalid value type '${value.javaClass.simpleName}' passed for setting: $key = $value") + } + + property.on = update + updateSwitchable(property) + return true + } + + private fun getUserSettings(): UserProperties { + + val userProperties = UserProperties() + // Publisher default system + userProperties.addSwitchable("readium-advanced-off", "readium-advanced-on", publisherDefaults, PUBLISHER_DEFAULT_REF, PUBLISHER_DEFAULT_NAME) + // Font override + userProperties.addSwitchable("readium-font-on", "readium-font-off", fontOverride, FONT_OVERRIDE_REF, FONT_OVERRIDE_NAME) + // Column count + userProperties.addEnumerable(columnCount, columnCountValues, COLUMN_COUNT_REF, COLUMN_COUNT_NAME) + // Appearance + userProperties.addEnumerable(appearance, appearanceValues, APPEARANCE_REF, APPEARANCE_NAME) + // Page margins + userProperties.addIncremental(pageMargins, 0.5f, 4f, 0.25f, "", PAGE_MARGINS_REF, PAGE_MARGINS_NAME) + // Text alignment + userProperties.addEnumerable(textAlignment, textAlignmentValues, TEXT_ALIGNMENT_REF, TEXT_ALIGNMENT_NAME) + // Font family + userProperties.addEnumerable(fontFamily, fontFamilyValues, FONT_FAMILY_REF, FONT_FAMILY_NAME) + // Font size + userProperties.addIncremental(fontSize, 100f, 300f, 25f, "%", FONT_SIZE_REF, FONT_SIZE_NAME) + // Line height + userProperties.addIncremental(lineHeight, 1f, 2f, 0.25f, "", LINE_HEIGHT_REF, LINE_HEIGHT_NAME) + // Word spacing + userProperties.addIncremental(wordSpacing, 0f, 0.5f, 0.25f, "rem", WORD_SPACING_REF, WORD_SPACING_NAME) + // Letter spacing + userProperties.addIncremental(letterSpacing, 0f, 0.5f, 0.0625f, "em", LETTER_SPACING_REF, LETTER_SPACING_NAME) + // Scroll + userProperties.addSwitchable("readium-scroll-on", "readium-scroll-off", verticalScroll, SCROLL_REF, SCROLL_NAME) + + return userProperties + } + + private fun makeJson(): JSONArray { + val array = JSONArray() + for (userProperty in userProperties.properties) { + array.put(userProperty.getJson()) + } + return array + } + + + fun saveChanges() { + val json = makeJson() + val dir = File(context.filesDir.path + "/" + Injectable.Style.rawValue + "/") + dir.mkdirs() + val file = File(dir, "UserProperties.json") + file.printWriter().use { out -> + out.println(json) + } + } + + private fun updateEnumerable(enumerable: Enumerable) { + preferences.edit().putInt(enumerable.ref, enumerable.index).apply() + saveChanges() + } + + + private fun updateSwitchable(switchable: Switchable) { + preferences.edit().putBoolean(switchable.ref, switchable.on).apply() + saveChanges() + } + + private fun updateIncremental(incremental: Incremental) { + preferences.edit().putFloat(incremental.ref, incremental.value).apply() + saveChanges() + } + + fun updateViewCSS(ref: String) { + for (i in 0 until resourcePager.childCount) { + val webView = resourcePager.getChildAt(i).findViewById(R.id.webView) as? R2WebView + webView?.let { + applyCSS(webView, ref) + } ?: run { + val zoomView = resourcePager.getChildAt(i).findViewById(R.id.r2FXLLayout) as R2FXLLayout + val webView1 = zoomView.findViewById(R.id.firstWebView) as? R2BasicWebView + val webView2 = zoomView.findViewById(R.id.secondWebView) as? R2BasicWebView + val webViewSingle = zoomView.findViewById(R.id.webViewSingle) as? R2BasicWebView + + webView1?.let { + applyCSS(webView1, ref) + } + webView2?.let { + applyCSS(webView2, ref) + } + webViewSingle?.let { + applyCSS(webViewSingle, ref) + } + } + } + } + + private fun applyCSS(view: R2BasicWebView, ref: String) { + val userSetting = userProperties.getByRef(ref) + view.setProperty(userSetting.name, userSetting.toString()) + } +} diff --git a/android/src/main/java/com/reactnativereadium/reader/BaseReaderFragment.kt b/android/src/main/java/com/reactnativereadium/reader/BaseReaderFragment.kt new file mode 100644 index 0000000..2db9aaa --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/BaseReaderFragment.kt @@ -0,0 +1,81 @@ +package com.reactnativereadium.reader + +import android.os.Bundle +import android.view.* +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.readium.r2.navigator.* +import org.readium.r2.shared.publication.Locator +import com.reactnativereadium.R +import com.reactnativereadium.utils.EventChannel +import kotlinx.coroutines.channels.Channel + +/* + * Base reader fragment class + * + * Provides common menu items and saves last location on stop. + */ +@OptIn(ExperimentalDecorator::class) +abstract class BaseReaderFragment : Fragment() { + val channel = EventChannel( + Channel(Channel.BUFFERED), + lifecycleScope + ) + + protected abstract val model: ReaderViewModel + protected abstract val navigator: Navigator + + override fun onCreate(savedInstanceState: Bundle?) { + setHasOptionsMenu(true) + super.onCreate(savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val viewScope = viewLifecycleOwner.lifecycleScope + + navigator.currentLocator + .onEach { channel.send(ReaderViewModel.Event.LocatorUpdate(it)) } + .launchIn(viewScope) + } + + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + setMenuVisibility(!hidden) + requireActivity().invalidateOptionsMenu() + } + + // TODO: this should probably be removed + override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_reader, menu) + menu.findItem(R.id.drm).isVisible = false + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.toc -> { + model.channel.send(ReaderViewModel.Event.OpenOutlineRequested) + true + } + R.id.drm -> { + model.channel.send(ReaderViewModel.Event.OpenDrmManagementRequested) + true + } + else -> false + } + } + + fun go(locator: Locator, animated: Boolean): Boolean { + // don't attempt to navigate if we're already there + val currentLocator = navigator.currentLocator.value + if (locator.hashCode() == currentLocator.hashCode()) { + return true + } + + return navigator.go(locator, animated) + } + +} diff --git a/android/src/main/java/com/reactnativereadium/reader/EpubReaderFragment.kt b/android/src/main/java/com/reactnativereadium/reader/EpubReaderFragment.kt new file mode 100644 index 0000000..86506a5 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/EpubReaderFragment.kt @@ -0,0 +1,375 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.reader + +import android.graphics.Color +import android.graphics.PointF +import android.os.Bundle +import android.view.* +import android.view.accessibility.AccessibilityManager +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.commitNow +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.ViewModelProvider +import com.reactnativereadium.epub.UserSettings +import com.reactnativereadium.R +import com.reactnativereadium.utils.toggleSystemUi +import java.net.URL +import kotlinx.coroutines.delay +import org.readium.r2.navigator.epub.EpubNavigatorFragment +import org.readium.r2.navigator.ExperimentalDecorator +import org.readium.r2.navigator.Navigator +import org.readium.r2.shared.APPEARANCE_REF +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.ReadiumCSSName +import org.readium.r2.shared.SCROLL_REF + +@OptIn(ExperimentalDecorator::class) +class EpubReaderFragment : VisualReaderFragment(), EpubNavigatorFragment.Listener { + + override lateinit var model: ReaderViewModel + override lateinit var navigator: Navigator + private lateinit var publication: Publication + lateinit var navigatorFragment: EpubNavigatorFragment + private lateinit var factory: ReaderViewModel.Factory + + private lateinit var menuScreenReader: MenuItem + private lateinit var menuSearch: MenuItem + lateinit var menuSearchView: SearchView + + private lateinit var userSettings: UserSettings + private var isScreenReaderVisible = false + private var isSearchViewIconified = true + + // Accessibility + private var isExploreByTouchEnabled = false + + fun initFactory( + publication: Publication, + initialLocation: Locator? + ) { + factory = ReaderViewModel.Factory( + publication, + initialLocation + ) + } + + fun updateSettingsFromMap(map: Map) { + if (userSettings != null) { + userSettings.updateSettingsFromMap(map) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + // FIXME: this should be checked + // check(R2App.isServerStarted) + + if (savedInstanceState != null) { + isScreenReaderVisible = savedInstanceState.getBoolean(IS_SCREEN_READER_VISIBLE_KEY) + isSearchViewIconified = savedInstanceState.getBoolean(IS_SEARCH_VIEW_ICONIFIED) + } + + ViewModelProvider(this, factory) + .get(ReaderViewModel::class.java) + .let { + model = it + publication = it.publication + } + + val baseUrl = checkNotNull(requireArguments().getString(BASE_URL_ARG)) + + childFragmentManager.fragmentFactory = + EpubNavigatorFragment.createFactory( + publication = publication, + baseUrl = baseUrl, + initialLocator = model.initialLocation, + listener = this, + config = EpubNavigatorFragment.Configuration().apply { + // Register the HTML template for our custom [DecorationStyleAnnotationMark]. + // TODO: remove? + /* decorationTemplates[DecorationStyleAnnotationMark::class] = annotationMarkTemplate(activity) */ + /* selectionActionModeCallback = customSelectionActionModeCallback */ + } + ) +// TODO: add search back in +// childFragmentManager.setFragmentResultListener( +// SearchFragment::class.java.name, +// this, +// FragmentResultListener { _, result -> +// menuSearch.collapseActionView() +// result.getParcelable(SearchFragment::class.java.name)?.let { +// navigatorFragment.go(it) +// } +// } +// ) +// TODO: add TTS back in +// childFragmentManager.setFragmentResultListener( +// ScreenReaderContract.REQUEST_KEY, +// this, +// FragmentResultListener { _, result -> +// val locator = ScreenReaderContract.parseResult(result).locator +// if (locator.href != navigator.currentLocator.value.href) { +// navigator.go(locator) +// } +// } +// ) + + setHasOptionsMenu(true) + + super.onCreate(savedInstanceState) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + val navigatorFragmentTag = getString(R.string.epub_navigator_tag) + + if (savedInstanceState == null) { + childFragmentManager.commitNow { + add(R.id.fragment_reader_container, EpubNavigatorFragment::class.java, Bundle(), navigatorFragmentTag) + } + } + navigator = childFragmentManager.findFragmentByTag(navigatorFragmentTag) as Navigator + navigatorFragment = navigator as EpubNavigatorFragment + + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val activity = requireActivity() + userSettings = UserSettings(navigatorFragment.preferences, activity, publication.userSettingsUIPreset) + + // This is a hack to draw the right background color on top and bottom blank spaces + navigatorFragment.lifecycleScope.launchWhenStarted { + val appearancePref = navigatorFragment.preferences.getInt(APPEARANCE_REF, 0) + val backgroundsColors = mutableListOf("#ffffff", "#faf4e8", "#000000") + navigatorFragment.resourcePager.setBackgroundColor(Color.parseColor(backgroundsColors[appearancePref])) + } + } + + override fun onResume() { + super.onResume() + val activity = requireActivity() + + userSettings.resourcePager = navigatorFragment.resourcePager + + // If TalkBack or any touch exploration service is activated we force scroll mode (and + // override user preferences) + val am = activity.getSystemService(AppCompatActivity.ACCESSIBILITY_SERVICE) as AccessibilityManager + isExploreByTouchEnabled = am.isTouchExplorationEnabled + + if (isExploreByTouchEnabled) { + // Preset & preferences adapted + publication.userSettingsUIPreset[ReadiumCSSName.ref(SCROLL_REF)] = true + navigatorFragment.preferences.edit().putBoolean(SCROLL_REF, true).apply() //overriding user preferences + userSettings.saveChanges() + + lifecycleScope.launchWhenResumed { + delay(500) + userSettings.updateViewCSS(SCROLL_REF) + } + } else { + if (publication.cssStyle != "cjk-vertical") { + publication.userSettingsUIPreset.remove(ReadiumCSSName.ref(SCROLL_REF)) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) { + super.onCreateOptionsMenu(menu, menuInflater) + menuInflater.inflate(R.menu.menu_epub, menu) + + menuScreenReader = menu.findItem(R.id.screen_reader) + menuSearch = menu.findItem(R.id.search) + menuSearchView = menuSearch.actionView as SearchView + + /* connectSearch() */ + if (!isSearchViewIconified) menuSearch.expandActionView() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(IS_SCREEN_READER_VISIBLE_KEY, isScreenReaderVisible) + outState.putBoolean(IS_SEARCH_VIEW_ICONIFIED, isSearchViewIconified) + } +// TODO: add search +// private fun connectSearch() { +// menuSearch.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { +// +// override fun onMenuItemActionExpand(item: MenuItem?): Boolean { +// if (isSearchViewIconified) { // It is not a state restoration. +// showSearchFragment() +// } +// +// isSearchViewIconified = false +// return true +// } +// +// override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { +// isSearchViewIconified = true +// childFragmentManager.popBackStack() +// menuSearchView.clearFocus() +// +// return true +// } +// }) +// +// menuSearchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { +// +// override fun onQueryTextSubmit(query: String): Boolean { +// model.search(query) +// menuSearchView.clearFocus() +// +// return false +// } +// +// override fun onQueryTextChange(s: String): Boolean { +// return false +// } +// }) +// +// menuSearchView.findViewById(R.id.search_close_btn).setOnClickListener { +// menuSearchView.requestFocus() +// model.cancelSearch() +// menuSearchView.setQuery("", false) +// +// (activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.showSoftInput( +// this.view, InputMethodManager.SHOW_FORCED +// ) +// } +// } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (super.onOptionsItemSelected(item)) { + return true + } + + return when (item.itemId) { + R.id.settings -> { + /* TODO: this should be stripped out */ + /* userSettings.userSettingsPopUp().showAsDropDown(requireActivity().findViewById(R.id.settings), 0, 0, Gravity.END) */ + true + } + R.id.search -> { + super.onOptionsItemSelected(item) + } + + android.R.id.home -> { + menuSearch.collapseActionView() + true + } + +// TODO: tts +// R.id.screen_reader -> { +// if (isScreenReaderVisible) { +// closeScreenReaderFragment() +// } else { +// showScreenReaderFragment() +// } +// true +// } + else -> false + } + } + + override fun onTap(point: PointF): Boolean { + requireActivity().toggleSystemUi() + return true + } +// TODO: search +// private fun showSearchFragment() { +// childFragmentManager.commit { +// childFragmentManager.findFragmentByTag(SEARCH_FRAGMENT_TAG)?.let { remove(it) } +// add(R.id.fragment_reader_container, SearchFragment::class.java, Bundle(), SEARCH_FRAGMENT_TAG) +// hide(navigatorFragment) +// addToBackStack(SEARCH_FRAGMENT_TAG) +// } +// } + +// TODO: tts +// private fun showScreenReaderFragment() { +// menuScreenReader.title = resources.getString(R.string.epubactivity_read_aloud_stop) +// isScreenReaderVisible = true +// val arguments = ScreenReaderContract.createArguments(navigator.currentLocator.value) +// childFragmentManager.commit { +// add(R.id.fragment_reader_container, ScreenReaderFragment::class.java, arguments) +// hide(navigatorFragment) +// addToBackStack(null) +// } +// } +// +// private fun closeScreenReaderFragment() { +// menuScreenReader.title = resources.getString(R.string.epubactivity_read_aloud_start) +// isScreenReaderVisible = false +// childFragmentManager.popBackStack() +// } + + companion object { + + private const val BASE_URL_ARG = "baseUrl" + + private const val SEARCH_FRAGMENT_TAG = "search" + + private const val IS_SCREEN_READER_VISIBLE_KEY = "isScreenReaderVisible" + + private const val IS_SEARCH_VIEW_ICONIFIED = "isSearchViewIconified" + + fun newInstance(baseUrl: URL): EpubReaderFragment { + return EpubReaderFragment().apply { + arguments = Bundle().apply { + putString(BASE_URL_ARG, baseUrl.toString()) + } + } + } + } +} + +/* TODO: remove */ +// /** +// * Example of an HTML template for a custom Decoration Style. +// * +// * This one will display a tinted "pen" icon in the page margin to show that a highlight has an +// * associated note. +// */ +// @OptIn(ExperimentalDecorator::class) +// private fun annotationMarkTemplate(context: Context, @ColorInt defaultTint: Int = Color.YELLOW): HtmlDecorationTemplate { +// // Converts the pen icon to a base 64 data URL, to be embedded in the decoration stylesheet. +// // Alternatively, serve the image with the local HTTP server and use its URL. +// val imageUrl = ContextCompat.getDrawable(context, R.drawable.ic_baseline_edit_24) +// ?.toBitmap()?.toDataUrl() +// requireNotNull(imageUrl) +// +// val className = "testapp-annotation-mark" +// return HtmlDecorationTemplate( +// layout = HtmlDecorationTemplate.Layout.BOUNDS, +// width = HtmlDecorationTemplate.Width.PAGE, +// element = { decoration -> +// val style = decoration.style as? DecorationStyleAnnotationMark +// val tint = style?.tint ?: defaultTint +// // Using `data-activable=1` prevents the whole decoration container from being +// // clickable. Only the icon will respond to activation events. +// """ +//
" +// """ +// }, +// stylesheet = """ +// .$className { +// float: left; +// margin-left: 8px; +// width: 30px; +// height: 30px; +// border-radius: 50%; +// background: url('$imageUrl') no-repeat center; +// background-size: auto 50%; +// opacity: 0.8; +// } +// """ +// ) +// } diff --git a/android/src/main/java/com/reactnativereadium/reader/ImageReaderFragment.kt b/android/src/main/java/com/reactnativereadium/reader/ImageReaderFragment.kt new file mode 100644 index 0000000..57a6523 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/ImageReaderFragment.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.reader + +import android.graphics.PointF +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.commitNow +import androidx.lifecycle.ViewModelProvider +import org.readium.r2.navigator.Navigator +import org.readium.r2.navigator.image.ImageNavigatorFragment +import org.readium.r2.shared.publication.Publication +import com.reactnativereadium.R +import com.reactnativereadium.utils.toggleSystemUi + +class ImageReaderFragment : VisualReaderFragment(), ImageNavigatorFragment.Listener { + + override lateinit var model: ReaderViewModel + override lateinit var navigator: Navigator + private lateinit var publication: Publication + + override fun onCreate(savedInstanceState: Bundle?) { + ViewModelProvider(requireActivity()).get(ReaderViewModel::class.java).let { + model = it + publication = it.publication + } + + childFragmentManager.fragmentFactory = + ImageNavigatorFragment.createFactory(publication, model.initialLocation, this) + + super.onCreate(savedInstanceState) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + if (savedInstanceState == null) { + childFragmentManager.commitNow { + add(R.id.fragment_reader_container, ImageNavigatorFragment::class.java, Bundle(), NAVIGATOR_FRAGMENT_TAG) + } + } + navigator = childFragmentManager.findFragmentByTag(NAVIGATOR_FRAGMENT_TAG)!! as Navigator + return view + } + + override fun onTap(point: PointF): Boolean { + val viewWidth = requireView().width + val leftRange = 0.0..(0.2 * viewWidth) + + when { + leftRange.contains(point.x) -> navigator.goBackward(animated = true) + leftRange.contains(viewWidth - point.x) -> navigator.goForward(animated = true) + else -> requireActivity().toggleSystemUi() + } + + return true + } + + companion object { + + const val NAVIGATOR_FRAGMENT_TAG = "navigator" + } +} \ No newline at end of file diff --git a/android/src/main/java/com/reactnativereadium/reader/PdfReaderFragment.kt b/android/src/main/java/com/reactnativereadium/reader/PdfReaderFragment.kt new file mode 100644 index 0000000..b64064f --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/PdfReaderFragment.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.reader + +import android.graphics.PointF +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.commitNow +import androidx.lifecycle.ViewModelProvider +import org.readium.r2.navigator.Navigator +import org.readium.r2.navigator.pdf.PdfNavigatorFragment +import org.readium.r2.shared.fetcher.Resource +import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.Publication +import com.reactnativereadium.R +import com.reactnativereadium.utils.toggleSystemUi + +class PdfReaderFragment : VisualReaderFragment(), PdfNavigatorFragment.Listener { + + override lateinit var model: ReaderViewModel + override lateinit var navigator: Navigator + private lateinit var publication: Publication + + override fun onCreate(savedInstanceState: Bundle?) { + ViewModelProvider(requireActivity()).get(ReaderViewModel::class.java).let { + model = it + publication = it.publication + } + + childFragmentManager.fragmentFactory = + PdfNavigatorFragment.createFactory(publication, model.initialLocation, this) + + super.onCreate(savedInstanceState) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + if (savedInstanceState == null) { + childFragmentManager.commitNow { + add(R.id.fragment_reader_container, PdfNavigatorFragment::class.java, Bundle(), NAVIGATOR_FRAGMENT_TAG) + } + } + navigator = childFragmentManager.findFragmentByTag(NAVIGATOR_FRAGMENT_TAG)!! as Navigator + return view + } + + override fun onResourceLoadFailed(link: Link, error: Resource.Exception) { + val message = when (error) { + is Resource.Exception.OutOfMemory -> "The PDF is too large to be rendered on this device" + else -> "Failed to render this PDF" + } + Toast.makeText(requireActivity(), message, Toast.LENGTH_LONG).show() + + // There's nothing we can do to recover, so we quit the Activity. + requireActivity().finish() + } + + override fun onTap(point: PointF): Boolean { + val viewWidth = requireView().width + val leftRange = 0.0..(0.2 * viewWidth) + + when { + leftRange.contains(point.x) -> navigator.goBackward() + leftRange.contains(viewWidth - point.x) -> navigator.goForward() + else -> requireActivity().toggleSystemUi() + } + + return true + } + + companion object { + + const val NAVIGATOR_FRAGMENT_TAG = "navigator" + } +} \ No newline at end of file diff --git a/android/src/main/java/com/reactnativereadium/reader/ReaderService.kt b/android/src/main/java/com/reactnativereadium/reader/ReaderService.kt new file mode 100644 index 0000000..ed24761 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/ReaderService.kt @@ -0,0 +1,116 @@ +package com.reactnativereadium.reader + +import android.annotation.SuppressLint +import androidx.lifecycle.ViewModelStore +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.util.RNLog +import java.io.File +import java.io.IOException +import java.net.ServerSocket +import java.net.URL +import org.readium.r2.shared.extensions.mediaType +import org.readium.r2.shared.extensions.tryOrNull +import org.readium.r2.shared.Injectable +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.asset.FileAsset +import org.readium.r2.shared.publication.Publication +import org.readium.r2.streamer.server.Server +import org.readium.r2.streamer.Streamer + + +class ReaderService( + private val reactContext: ReactApplicationContext +) { + private var streamer = Streamer(reactContext) + // see R2App.onCreate + private var server: Server + // val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + private var store = ViewModelStore() + + companion object { + @SuppressLint("StaticFieldLeak") + lateinit var server: Server + private set + + lateinit var R2DIRECTORY: String + private set + + var isServerStarted = false + private set + } + + init { + val s = ServerSocket(0) + s.close() + server = Server(s.localPort, reactContext) + this.startServer() + } + + suspend fun openPublication( + fileName: String, + initialLocation: Locator?, + callback: suspend (fragment: BaseReaderFragment) -> Unit + ) { + val file = File(fileName) + val asset = FileAsset(file, file.mediaType()) + + streamer.open( + asset, + allowUserInteraction = false, + sender = reactContext + ) + .onSuccess { + val url = prepareToServe(it) + if (url != null) { + val readerFragment = EpubReaderFragment.newInstance(url) + readerFragment.initFactory(it, initialLocation) + callback.invoke(readerFragment) + } + } + .onFailure { + tryOrNull { asset.file.delete() } + RNLog.w(reactContext, "Error executing ReaderService.openPublication") + // TODO: implement failure event + } + } + + private fun prepareToServe(publication: Publication): URL? { + val userProperties = + reactContext.filesDir.path + "/" + Injectable.Style.rawValue + "/UserProperties.json" + return server.addPublication( + publication, + userPropertiesFile = File(userProperties) + ) + } + + private fun startServer() { + if (!server.isAlive) { + try { + server.start() + } catch (e: IOException) { + RNLog.e(reactContext, "Unable to start the Readium server.") + } + if (server.isAlive) { + // // Add your own resources here + // server.loadCustomResource(assets.open("scripts/test.js"), "test.js") + // server.loadCustomResource(assets.open("styles/test.css"), "test.css") + // server.loadCustomFont(assets.open("fonts/test.otf"), applicationContext, "test.otf") + + isServerStarted = true + } + } + } + + sealed class Event { + + class ImportPublicationFailed(val errorMessage: String?) : Event() + + object UnableToMovePublication : Event() + + object ImportPublicationSuccess : Event() + + object ImportDatabaseFailed : Event() + + class OpenBookError(val errorMessage: String?) : Event() + } +} diff --git a/android/src/main/java/com/reactnativereadium/reader/ReaderViewModel.kt b/android/src/main/java/com/reactnativereadium/reader/ReaderViewModel.kt new file mode 100644 index 0000000..cae408b --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/ReaderViewModel.kt @@ -0,0 +1,120 @@ +package com.reactnativereadium.reader + +import android.graphics.Color +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.paging.* +import com.reactnativereadium.search.SearchPagingSource +import com.reactnativereadium.utils.EventChannel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.readium.r2.navigator.Decoration +import org.readium.r2.navigator.ExperimentalDecorator +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.LocatorCollection +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.services.search.search +import org.readium.r2.shared.publication.services.search.SearchIterator +import org.readium.r2.shared.publication.services.search.SearchTry +import org.readium.r2.shared.Search +import org.readium.r2.shared.UserException +import org.readium.r2.shared.util.Try + +@OptIn(Search::class, ExperimentalDecorator::class) +class ReaderViewModel( + val publication: Publication, + val initialLocation: Locator? +) : ViewModel() { + val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + + fun search(query: String) = viewModelScope.launch { + if (query == lastSearchQuery) return@launch + lastSearchQuery = query + _searchLocators.value = emptyList() + searchIterator = publication.search(query) + .onFailure { channel.send(Event.Failure(it)) } + .getOrNull() + pagingSourceFactory.invalidate() + channel.send(Event.StartNewSearch) + } + + fun cancelSearch() = viewModelScope.launch { + _searchLocators.value = emptyList() + searchIterator?.close() + searchIterator = null + pagingSourceFactory.invalidate() + } + + val searchLocators: StateFlow> get() = _searchLocators + private var _searchLocators = MutableStateFlow>(emptyList()) + + /** + * Maps the current list of search result locators into a list of [Decoration] objects to + * underline the results in the navigator. + */ + val searchDecorations: Flow> by lazy { + searchLocators.map { + it.mapIndexed { index, locator -> + Decoration( + // The index in the search result list is a suitable Decoration ID, as long as + // we clear the search decorations between two searches. + id = index.toString(), + locator = locator, + style = Decoration.Style.Underline(tint = Color.RED) + ) + } + } + } + + private var lastSearchQuery: String? = null + + private var searchIterator: SearchIterator? = null + + private val pagingSourceFactory = InvalidatingPagingSourceFactory { + SearchPagingSource(listener = PagingSourceListener()) + } + + inner class PagingSourceListener : SearchPagingSource.Listener { + override suspend fun next(): SearchTry { + val iterator = searchIterator ?: return Try.success(null) + return iterator.next().onSuccess { + _searchLocators.value += (it?.locators ?: emptyList()) + } + } + } + + val searchResult: Flow> = + Pager(PagingConfig(pageSize = 20), pagingSourceFactory = pagingSourceFactory) + .flow.cachedIn(viewModelScope) + + class Factory( + private val publication: Publication, + private val initialLocation: Locator? + ) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T = + modelClass + .getDeclaredConstructor( + Publication::class.java, + Locator::class.java + ) + .newInstance( + publication, + initialLocation + ) + } + + sealed class Event { + object OpenOutlineRequested : Event() + object OpenDrmManagementRequested : Event() + object StartNewSearch : Event() + class Failure(val error: UserException) : Event() + class LocatorUpdate(val locator: Locator) : Event() + } + + sealed class FeedbackEvent { + object BookmarkSuccessfullyAdded : FeedbackEvent() + object BookmarkFailed : FeedbackEvent() + } +} diff --git a/android/src/main/java/com/reactnativereadium/reader/VisualReaderFragment.kt b/android/src/main/java/com/reactnativereadium/reader/VisualReaderFragment.kt new file mode 100644 index 0000000..a25204c --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/reader/VisualReaderFragment.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.reader + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import org.readium.r2.navigator.DecorableNavigator +import org.readium.r2.navigator.ExperimentalDecorator +import com.reactnativereadium.R +import com.reactnativereadium.databinding.FragmentReaderBinding +import com.reactnativereadium.utils.clearPadding +import com.reactnativereadium.utils.hideSystemUi +import com.reactnativereadium.utils.padSystemUi +import com.reactnativereadium.utils.showSystemUi + +/* + * Adds fullscreen support to the BaseReaderFragment + */ +abstract class VisualReaderFragment : BaseReaderFragment() { + + private lateinit var navigatorFragment: Fragment + + private var _binding: FragmentReaderBinding? = null + val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = FragmentReaderBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + navigatorFragment = navigator as Fragment + + childFragmentManager.addOnBackStackChangedListener { + updateSystemUiVisibility() + } + binding.fragmentReaderContainer.setOnApplyWindowInsetsListener { container, insets -> + updateSystemUiPadding(container, insets) + insets + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + fun updateSystemUiVisibility() { + if (navigatorFragment.isHidden) + requireActivity().showSystemUi() + else + requireActivity().hideSystemUi() + + requireView().requestApplyInsets() + } + + private fun updateSystemUiPadding(container: View, insets: WindowInsets) { + if (navigatorFragment.isHidden) { + container.padSystemUi(insets, requireActivity()) + } else { + container.clearPadding() + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/reactnativereadium/search/SearchFragment.kt b/android/src/main/java/com/reactnativereadium/search/SearchFragment.kt new file mode 100644 index 0000000..069e661 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/search/SearchFragment.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.readium.r2.shared.publication.Locator +import com.reactnativereadium.R +import com.reactnativereadium.databinding.FragmentSearchBinding +import com.reactnativereadium.reader.ReaderViewModel +import com.reactnativereadium.utils.SectionDecoration + +class SearchFragment : Fragment(R.layout.fragment_search) { + + private val viewModel: ReaderViewModel by activityViewModels() + + private var _binding: FragmentSearchBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val viewScope = viewLifecycleOwner.lifecycleScope + + val searchAdapter = SearchResultAdapter(object : SearchResultAdapter.Listener { + override fun onItemClicked(v: View, locator: Locator) { + val result = Bundle().apply { + putParcelable(SearchFragment::class.java.name, locator) + } + setFragmentResult(SearchFragment::class.java.name, result) + } + }) + + viewModel.searchResult + .onEach { searchAdapter.submitData(it) } + .launchIn(viewScope) + + viewModel.searchLocators + .onEach { binding.noResultLabel.isVisible = it.isEmpty() } + .launchIn(viewScope) + + viewModel.channel + .receive(viewLifecycleOwner) { event -> + when (event) { + ReaderViewModel.Event.StartNewSearch -> + binding.searchRecyclerView.scrollToPosition(0) + else -> {} + } + } + + binding.searchRecyclerView.apply { + adapter = searchAdapter + layoutManager = LinearLayoutManager(activity) + addItemDecoration(SectionDecoration(context, object : SectionDecoration.Listener { + override fun isStartOfSection(itemPos: Int): Boolean = + viewModel.searchLocators.value.run { + when { + itemPos == 0 -> true + itemPos < 0 -> false + itemPos >= size -> false + else -> getOrNull(itemPos)?.title != getOrNull(itemPos-1)?.title + } + } + + override fun sectionTitle(itemPos: Int): String = + viewModel.searchLocators.value.getOrNull(itemPos)?.title ?: "" + })) + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSearchBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/src/main/java/com/reactnativereadium/search/SearchPagingSource.kt b/android/src/main/java/com/reactnativereadium/search/SearchPagingSource.kt new file mode 100644 index 0000000..0c22abe --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/search/SearchPagingSource.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.search + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import org.readium.r2.shared.Search +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.LocatorCollection +import org.readium.r2.shared.publication.services.search.SearchIterator +import org.readium.r2.shared.publication.services.search.SearchTry + +@OptIn(Search::class) +class SearchPagingSource( + private val listener: Listener? +) : PagingSource() { + + interface Listener { + suspend fun next(): SearchTry + } + + override val keyReuseSupported: Boolean get() = true + + override fun getRefreshKey(state: PagingState): Unit? = null + + override suspend fun load(params: LoadParams): LoadResult { + listener ?: return LoadResult.Page(data = emptyList(), prevKey = null, nextKey = null) + + return try { + val page = listener.next().getOrThrow() + LoadResult.Page( + data = page?.locators ?: emptyList(), + prevKey = null, + nextKey = if (page == null) null else Unit + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/android/src/main/java/com/reactnativereadium/search/SearchResultAdapter.kt b/android/src/main/java/com/reactnativereadium/search/SearchResultAdapter.kt new file mode 100644 index 0000000..25a6594 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/search/SearchResultAdapter.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package com.reactnativereadium.search + +import android.os.Build +import android.text.Html +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.readium.r2.shared.publication.Locator +import com.reactnativereadium.databinding.ItemRecycleSearchBinding +import com.reactnativereadium.utils.singleClick + +/** + * This class is an adapter for Search results' recycler view. + */ +class SearchResultAdapter(private var listener: Listener) : + PagingDataAdapter(ItemCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemRecycleSearchBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val locator = getItem(position) ?: return + val html = + "${locator.text.before}${locator.text.highlight}${locator.text.after}" + holder.textView.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT) + } else { + @Suppress("DEPRECATION") + Html.fromHtml(html) + } + + holder.itemView.singleClick { v -> + listener.onItemClicked(v, locator) + } + } + + inner class ViewHolder(val binding: ItemRecycleSearchBinding) : + RecyclerView.ViewHolder(binding.root) { + val textView = binding.text + } + + interface Listener { + fun onItemClicked(v: View, locator: Locator) + } + + private class ItemCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: Locator, newItem: Locator): Boolean = + oldItem == newItem + + override fun areContentsTheSame(oldItem: Locator, newItem: Locator): Boolean = + oldItem == newItem + } +} diff --git a/android/src/main/java/com/reactnativereadium/utils/ContentResolverUtil.kt b/android/src/main/java/com/reactnativereadium/utils/ContentResolverUtil.kt new file mode 100644 index 0000000..5dfc5b3 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/ContentResolverUtil.kt @@ -0,0 +1,156 @@ +package com.reactnativereadium.utils + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.text.TextUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.reactnativereadium.utils.extensions.toFile +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import java.net.URL + +object ContentResolverUtil { + + suspend fun getContentInputStream(context: Context, uri: Uri, publicationPath: String) { + withContext(Dispatchers.IO) { + try { + val path = getRealPath(context, uri) + if (path != null) { + File(path).copyTo(File(publicationPath)) + } else { + val input = URL(uri.toString()).openStream() + input.toFile(publicationPath) + } + } catch (e: Exception) { + val input = getInputStream(context, uri) + input?.let { + input.toFile(publicationPath) + } + } + } + } + + private fun getInputStream(context: Context, uri: Uri): InputStream? { + return try { + context.contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + e.printStackTrace() + null + } + } + + private fun getRealPath(context: Context, uri: Uri): String? { + // DocumentProvider + if (DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + return context.getExternalFilesDir(null).toString() + "/" + split[1] + } + // TODO handle non-primary volumes + + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + if (!TextUtils.isEmpty(id)) { + if (id.startsWith("raw:")) { + return id.replaceFirst("raw:".toRegex(), "") + } + return try { + val contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) + getDataColumn(context, contentUri, null, null) + } catch (e: NumberFormatException) { + null + } + + } + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val type = split[0] + + var contentUri: Uri? = null + when (type) { + "image" -> contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + "video" -> contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + "audio" -> contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } + + val selection = "_id=?" + val selectionArgs = arrayOf(split[1]) + + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } else if ("content".equals(uri.scheme!!, ignoreCase = true)) { + + // Return the remote address + return getDataColumn(context, uri, null, null) + + } else if ("file".equals(uri.scheme!!, ignoreCase = true)) { + return uri.path + } + + return null + } + + /** + * Get the value of the data column for this Uri. This is useful for + * MediaStore Uris, and other file-based ContentProviders. + * + * @param context The context. + * @param uri The Uri to query. + * @param selection (Optional) Filter used in the query. + * @param selectionArgs (Optional) Selection arguments used in the query. + * @return The value of the _data column, which is typically a file path. + */ + private fun getDataColumn(context: Context, uri: Uri?, selection: String?, + selectionArgs: Array?): String? { + + val column = "_data" + val projection = arrayOf(column) + context.contentResolver.query(uri!!, projection, selection, selectionArgs, null).use { cursor -> + cursor?.let { + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndexOrThrow(column) + return cursor.getString(index) + } + } + } + return null + } + + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + private fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + private fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.authority + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + private fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.authority + } + + +} diff --git a/android/src/main/java/com/reactnativereadium/utils/Dimensions.kt b/android/src/main/java/com/reactnativereadium/utils/Dimensions.kt new file mode 100644 index 0000000..d58ba9f --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/Dimensions.kt @@ -0,0 +1,6 @@ +package com.reactnativereadium.utils + +class Dimensions ( + var width: Int = 0, + var height: Int = 0 +) {} diff --git a/android/src/main/java/com/reactnativereadium/utils/EventChannel.kt b/android/src/main/java/com/reactnativereadium/utils/EventChannel.kt new file mode 100644 index 0000000..7612b85 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/EventChannel.kt @@ -0,0 +1,61 @@ +package com.reactnativereadium.utils + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +class EventChannel(private val channel: Channel, private val sendScope: CoroutineScope) { + + fun send(event: T) { + sendScope.launch { + channel.send(event) + } + } + + fun receive(lifecycleOwner: LifecycleOwner, callback: suspend (T) -> Unit) { + val observer = FlowObserver(lifecycleOwner, channel.receiveAsFlow(), callback) + lifecycleOwner.lifecycle.addObserver(observer) + } +} + +class FlowObserver ( + private val lifecycleOwner: LifecycleOwner, + private val flow: Flow, + private val collector: suspend (T) -> Unit +) : LifecycleObserver { + + private var job: Job? = null + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onStart() { + if (job == null) { + job = lifecycleOwner.lifecycleScope.launch { + flow.collect { collector(it) } + } + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onStop() { + job?.cancel() + job = null + } +} + + +inline fun Flow.observeWhenStarted( + lifecycleOwner: LifecycleOwner, + noinline collector: suspend (T) -> Unit +) { + val observer = FlowObserver(lifecycleOwner, this, collector) + lifecycleOwner.lifecycle.addObserver(observer) +} diff --git a/android/src/main/java/com/reactnativereadium/utils/FragmentFactory.kt b/android/src/main/java/com/reactnativereadium/utils/FragmentFactory.kt new file mode 100644 index 0000000..1988348 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/FragmentFactory.kt @@ -0,0 +1,46 @@ +package com.reactnativereadium.utils + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import org.readium.r2.shared.extensions.tryOrNull + +/** + * Creates a [FragmentFactory] for a single type of [Fragment] using the result of the given + * [factory] closure. + */ +inline fun createFragmentFactory(crossinline factory: () -> T): FragmentFactory = object : FragmentFactory() { + + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + return when (className) { + T::class.java.name -> factory() + else -> super.instantiate(classLoader, className) + } + } + +} + +/** + * A [FragmentFactory] which will iterate over a provided list of [factories] until finding one + * instantiating successfully the requested [Fragment]. + * + * ``` + * supportFragmentManager.fragmentFactory = CompositeFragmentFactory( + * EpubNavigatorFragment.createFactory(publication, baseUrl, initialLocator, this), + * PdfNavigatorFragment.createFactory(publication, initialLocator, this) + * ) + * ``` + */ +class CompositeFragmentFactory(private val factories: List) : FragmentFactory() { + + constructor(vararg factories: FragmentFactory) : this(factories.toList()) + + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + for (factory in factories) { + tryOrNull { factory.instantiate(classLoader, className) } + ?.let { return it } + } + + return super.instantiate(classLoader, className) + } + +} diff --git a/android/src/main/java/com/reactnativereadium/utils/R2DispatcherActivity.kt b/android/src/main/java/com/reactnativereadium/utils/R2DispatcherActivity.kt new file mode 100644 index 0000000..c87ed20 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/R2DispatcherActivity.kt @@ -0,0 +1,45 @@ +package com.reactnativereadium.utils + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +//import com.reactnativereadium.MainActivity +import timber.log.Timber + +class R2DispatcherActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + dispatchIntent(intent) + finish() + } + + private fun dispatchIntent(intent: Intent) { + val uri = uriFromIntent(intent) + ?: run { + Timber.d("Got an empty intent.") + return + } +// FIXME: MainActivity +// val newIntent = Intent(this, MainActivity::class.java).apply { +// addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) +// data = uri +// } +// startActivity(newIntent) + } + + private fun uriFromIntent(intent: Intent): Uri? = + when (intent.action) { + Intent.ACTION_SEND -> { + if ("text/plain" == intent.type) { + intent.getStringExtra(Intent.EXTRA_TEXT).let { Uri.parse(it) } + } else { + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + } + else -> { + intent.data + } + } +} diff --git a/android/src/main/java/com/reactnativereadium/utils/SectionDecoration.kt b/android/src/main/java/com/reactnativereadium/utils/SectionDecoration.kt new file mode 100644 index 0000000..4be3132 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/SectionDecoration.kt @@ -0,0 +1,98 @@ +package com.reactnativereadium.utils + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_POSITION +/* import com.reactnativereadium.databinding.SectionHeaderBinding */ + +class SectionDecoration( + private val context: Context, + private val listener: Listener +) : RecyclerView.ItemDecoration() { + + interface Listener { + fun isStartOfSection(itemPos: Int): Boolean + fun sectionTitle(itemPos: Int): String + } + + private lateinit var headerView: View + private lateinit var sectionTitleView: TextView + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + super.getItemOffsets(outRect, view, parent, state) + val pos = parent.getChildAdapterPosition(view) + initHeaderViewIfNeeded(parent) + if (listener.sectionTitle(pos) != "" && listener.isStartOfSection(pos)) { + sectionTitleView.text = listener.sectionTitle(pos) + fixLayoutSize(headerView, parent) + outRect.top = headerView.height + } + } + + override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + super.onDrawOver(c, parent, state) + initHeaderViewIfNeeded(parent) + + val children = parent.children.toList() + children.forEach { child -> + val pos = parent.getChildAdapterPosition(child) + if (pos != NO_POSITION && listener.sectionTitle(pos) != "" && + (listener.isStartOfSection(pos) || isTopChild(child, children))) { + sectionTitleView.text = listener.sectionTitle(pos) + fixLayoutSize(headerView, parent) + drawHeader(c, child, headerView) + } + } + } + + private fun initHeaderViewIfNeeded(parent: RecyclerView) { + if (::headerView.isInitialized) return + /* FIXME: databinding.SectionHeaderBinding */ + /* SectionHeaderBinding.inflate( + LayoutInflater.from(context), + parent, + false + ).apply { + headerView = root + sectionTitleView = header + } */ + } + + private fun fixLayoutSize(v: View, parent: ViewGroup) { + val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) + val childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingStart + parent.paddingEnd, v.layoutParams.width) + val childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, v.layoutParams.height) + v.measure(childWidth, childHeight) + v.layout(0, 0, v.measuredWidth, v.measuredHeight) + } + + private fun drawHeader(c: Canvas, child: View, headerView: View) { + c.run { + save() + translate(0F, maxOf(0, child.top - headerView.height).toFloat()) + headerView.draw(this) + restore() + } + } + + private fun isTopChild(child: View, children: List): Boolean { + var tmp = child.top + children.forEach { c -> + tmp = minOf(c.top, tmp) + } + return child.top == tmp + } +} diff --git a/android/src/main/java/com/reactnativereadium/utils/SingleClickListener.kt b/android/src/main/java/com/reactnativereadium/utils/SingleClickListener.kt new file mode 100644 index 0000000..e84dbba --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/SingleClickListener.kt @@ -0,0 +1,32 @@ +package com.reactnativereadium.utils + +import android.view.View + + +/** + * Prevents from double clicks on a view, which could otherwise lead to unpredictable states. Useful + * while transitioning to another activity for instance. + */ +class SingleClickListener(private val click: (v: View) -> Unit) : View.OnClickListener { + + companion object { + private const val DOUBLE_CLICK_TIMEOUT = 2500 + } + + private var lastClick: Long = 0 + + override fun onClick(v: View) { + if (getLastClickTimeout() > DOUBLE_CLICK_TIMEOUT) { + lastClick = System.currentTimeMillis() + click(v) + } + } + + private fun getLastClickTimeout(): Long { + return System.currentTimeMillis() - lastClick + } +} + +fun View.singleClick(l: (View) -> Unit) { + setOnClickListener(SingleClickListener(l)) +} diff --git a/android/src/main/java/com/reactnativereadium/utils/SystemUiManagement.kt b/android/src/main/java/com/reactnativereadium/utils/SystemUiManagement.kt new file mode 100644 index 0000000..d973bc3 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/SystemUiManagement.kt @@ -0,0 +1,63 @@ +package com.reactnativereadium.utils + +import android.app.Activity +import android.view.View +import android.view.WindowInsets +import androidx.core.view.WindowInsetsCompat + +// Using ViewCompat and WindowInsetsCompat does not work properly in all versions of Android +@Suppress("DEPRECATION") +/** Returns `true` if fullscreen or immersive mode is not set. */ +private fun Activity.isSystemUiVisible(): Boolean { + return this.window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0 +} + +// Using ViewCompat and WindowInsetsCompat does not work properly in all versions of Android +@Suppress("DEPRECATION") +/** Enable fullscreen or immersive mode. */ +fun Activity.hideSystemUi() { + this.window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + ) +} + +// Using ViewCompat and WindowInsetsCompat does not work properly in all versions of Android +@Suppress("DEPRECATION") +/** Disable fullscreen or immersive mode. */ +fun Activity.showSystemUi() { + this.window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) +} + +/** Toggle fullscreen or immersive mode. */ +fun Activity.toggleSystemUi() { + if (this.isSystemUiVisible()) { + this.hideSystemUi() + } else { + this.showSystemUi() + } +} + +/** Set padding around view so that content doesn't overlap system UI */ +fun View.padSystemUi(insets: WindowInsets, activity: Activity) = + WindowInsetsCompat.toWindowInsetsCompat(insets, this) + .getInsets(WindowInsetsCompat.Type.systemBars()).apply { + setPadding( + left, + top, + right, + bottom + ) + } + +/** Clear padding around view */ +fun View.clearPadding() = + setPadding(0, 0, 0, 0) diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/Bitmap.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/Bitmap.kt new file mode 100644 index 0000000..ae99c8e --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/Bitmap.kt @@ -0,0 +1,23 @@ +package com.reactnativereadium.utils.extensions + +import android.graphics.Bitmap +import android.util.Base64 +import timber.log.Timber +import java.io.ByteArrayOutputStream + +/** + * Converts the receiver bitmap into a data URL ready to be used in HTML or CSS. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + */ +fun Bitmap.toDataUrl(): String? = + try { + val stream = ByteArrayOutputStream() + compress(Bitmap.CompressFormat.PNG, 100, stream) + .also { success -> if (!success) throw Exception("Can't compress image to PNG") } + val b64 = Base64.encodeToString(stream.toByteArray(), Base64.DEFAULT) + "data:image/png;base64,$b64" + } catch (e: Exception) { + Timber.e(e) + null + } diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/Context.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/Context.kt new file mode 100644 index 0000000..df4ffb1 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/Context.kt @@ -0,0 +1,16 @@ +package com.reactnativereadium.utils.extensions + +import android.content.Context +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat + + +/** + * Extensions + */ + +@ColorInt +fun Context.color(@ColorRes id: Int): Int { + return ContextCompat.getColor(this, id) +} diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/File.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/File.kt new file mode 100644 index 0000000..52dcc1a --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/File.kt @@ -0,0 +1,22 @@ +package com.reactnativereadium.utils.extensions + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileFilter +import java.io.IOException + +suspend fun File.moveTo(target: File) = withContext(Dispatchers.IO) { + if (!this@moveTo.renameTo(target)) + throw IOException() +} + + +/** + * As there are cases where [File.listFiles] returns null even though it is a directory, we return + * an empty list instead. + */ +fun File.listFilesSafely(filter: FileFilter? = null): List { + val array: Array? = if (filter == null) listFiles() else listFiles(filter) + return array?.toList() ?: emptyList() +} diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/InputStream.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/InputStream.kt new file mode 100644 index 0000000..abeb07e --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/InputStream.kt @@ -0,0 +1,23 @@ +package com.reactnativereadium.utils.extensions + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.readium.r2.shared.extensions.tryOrNull +import java.io.File +import java.io.InputStream +import java.util.* + + +suspend fun InputStream.toFile(path: String) { + withContext(Dispatchers.IO) { + use { input -> + File(path).outputStream().use { input.copyTo(it) } + } + } +} + +suspend fun InputStream.copyToTempFile(dir: String): File? = tryOrNull { + val filename = UUID.randomUUID().toString() + File(dir + filename) + .also { toFile(it.path) } +} diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/Link.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/Link.kt new file mode 100644 index 0000000..a838db7 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/Link.kt @@ -0,0 +1,6 @@ +package com.reactnativereadium.utils.extensions + +import org.readium.r2.shared.publication.Link + +val Link.outlineTitle: String + get() = title ?: href diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/Metadata.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/Metadata.kt new file mode 100644 index 0000000..cabd00a --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/Metadata.kt @@ -0,0 +1,6 @@ +package com.reactnativereadium.utils.extensions + +import org.readium.r2.shared.publication.Metadata + +val Metadata.authorName: String get() = + authors.firstOrNull()?.name ?: "" diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/URL.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/URL.kt new file mode 100644 index 0000000..8bddb50 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/URL.kt @@ -0,0 +1,29 @@ +package com.reactnativereadium.utils.extensions + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.readium.r2.shared.extensions.extension +import org.readium.r2.shared.extensions.tryOr +import org.readium.r2.shared.extensions.tryOrNull +import java.io.File +import java.io.FileOutputStream +import java.net.URL +import java.util.* + +suspend fun URL.download(path: String): File? = tryOr(null) { + val file = File(path) + withContext(Dispatchers.IO) { + openStream().use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } + } + file +} + +suspend fun URL.copyToTempFile(dir: String): File? = tryOrNull { + val filename = UUID.randomUUID().toString() + val path = "$dir$filename.$extension" + download(path) +} diff --git a/android/src/main/java/com/reactnativereadium/utils/extensions/Uri.kt b/android/src/main/java/com/reactnativereadium/utils/extensions/Uri.kt new file mode 100644 index 0000000..1267560 --- /dev/null +++ b/android/src/main/java/com/reactnativereadium/utils/extensions/Uri.kt @@ -0,0 +1,17 @@ +package com.reactnativereadium.utils.extensions + +import android.content.Context +import android.net.Uri +import org.readium.r2.shared.extensions.tryOrNull +import org.readium.r2.shared.util.mediatype.MediaType +import com.reactnativereadium.utils.ContentResolverUtil +import java.io.File +import java.util.* + +suspend fun Uri.copyToTempFile(context: Context, dir: String): File? = tryOrNull { + val filename = UUID.randomUUID().toString() + val mediaType = MediaType.ofUri(this, context.contentResolver) + val path = "$dir$filename.${mediaType?.fileExtension ?: "tmp"}" + ContentResolverUtil.getContentInputStream(context, this, path) + return@tryOrNull File(path) +} diff --git a/android/src/main/res/drawable/background_action_mode.xml b/android/src/main/res/drawable/background_action_mode.xml new file mode 100644 index 0000000..bab1a0d --- /dev/null +++ b/android/src/main/res/drawable/background_action_mode.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/cnl.png b/android/src/main/res/drawable/cnl.png new file mode 100644 index 0000000000000000000000000000000000000000..6dfe3affed84f808bb75d707fcc0c03b8d2bed40 GIT binary patch literal 20299 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%0Dz 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 + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_baseline_arrow_forward_24.xml b/android/src/main/res/drawable/ic_baseline_arrow_forward_24.xml new file mode 100644 index 0000000..8c3764b --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_arrow_forward_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_bookmark_24.xml b/android/src/main/res/drawable/ic_baseline_bookmark_24.xml new file mode 100644 index 0000000..7e480e2 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_bookmark_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_delete_24.xml b/android/src/main/res/drawable/ic_baseline_delete_24.xml new file mode 100644 index 0000000..79372b1 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_delete_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_edit_24.xml b/android/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 0000000..5fb90ad --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_enhanced_encryption_24.xml b/android/src/main/res/drawable/ic_baseline_enhanced_encryption_24.xml new file mode 100644 index 0000000..6c36aab --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_enhanced_encryption_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_fast_forward_24.xml b/android/src/main/res/drawable/ic_baseline_fast_forward_24.xml new file mode 100644 index 0000000..e3f30c6 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_fast_forward_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_fast_rewind_24.xml b/android/src/main/res/drawable/ic_baseline_fast_rewind_24.xml new file mode 100644 index 0000000..81f79b3 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_fast_rewind_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_headphones_24.xml b/android/src/main/res/drawable/ic_baseline_headphones_24.xml new file mode 100644 index 0000000..be84c72 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_headphones_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_pause_24.xml b/android/src/main/res/drawable/ic_baseline_pause_24.xml new file mode 100644 index 0000000..13d6d2e --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_play_arrow_24.xml b/android/src/main/res/drawable/ic_baseline_play_arrow_24.xml new file mode 100644 index 0000000..13c137a --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_search_24.xml b/android/src/main/res/drawable/ic_baseline_search_24.xml new file mode 100644 index 0000000..07b76d6 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_search_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_skip_next_24.xml b/android/src/main/res/drawable/ic_baseline_skip_next_24.xml new file mode 100644 index 0000000..4fff247 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_skip_next_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_skip_previous_24.xml b/android/src/main/res/drawable/ic_baseline_skip_previous_24.xml new file mode 100644 index 0000000..1805b7d --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_skip_previous_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_dashboard_black_24dp.xml b/android/src/main/res/drawable/ic_dashboard_black_24dp.xml new file mode 100644 index 0000000..cf9903c --- /dev/null +++ b/android/src/main/res/drawable/ic_dashboard_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_fastforward_30.xml b/android/src/main/res/drawable/ic_fastforward_30.xml new file mode 100644 index 0000000..090537b --- /dev/null +++ b/android/src/main/res/drawable/ic_fastforward_30.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_info_black_24dp.xml b/android/src/main/res/drawable/ic_info_black_24dp.xml new file mode 100644 index 0000000..31ad0cb --- /dev/null +++ b/android/src/main/res/drawable/ic_info_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_local_library_black_24dp.xml b/android/src/main/res/drawable/ic_local_library_black_24dp.xml new file mode 100644 index 0000000..0e88568 --- /dev/null +++ b/android/src/main/res/drawable/ic_local_library_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_notch.xml b/android/src/main/res/drawable/ic_notch.xml new file mode 100644 index 0000000..056aa8a --- /dev/null +++ b/android/src/main/res/drawable/ic_notch.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_add_24.xml b/android/src/main/res/drawable/ic_outline_add_24.xml new file mode 100644 index 0000000..fe04f24 --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_format_align_justify_24.xml b/android/src/main/res/drawable/ic_outline_format_align_justify_24.xml new file mode 100644 index 0000000..e587932 --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_format_align_justify_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_format_align_left_24.xml b/android/src/main/res/drawable/ic_outline_format_align_left_24.xml new file mode 100644 index 0000000..4ff0c3a --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_format_align_left_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_format_size_24.xml b/android/src/main/res/drawable/ic_outline_format_size_24.xml new file mode 100644 index 0000000..4cfa4c7 --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_format_size_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_light_mode_24.xml b/android/src/main/res/drawable/ic_outline_light_mode_24.xml new file mode 100644 index 0000000..5014eea --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_light_mode_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_menu_24.xml b/android/src/main/res/drawable/ic_outline_menu_24.xml new file mode 100644 index 0000000..4350ba9 --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_menu_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_remove_24.xml b/android/src/main/res/drawable/ic_outline_remove_24.xml new file mode 100644 index 0000000..86894f7 --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_remove_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_outline_wb_sunny_24.xml b/android/src/main/res/drawable/ic_outline_wb_sunny_24.xml new file mode 100644 index 0000000..7315059 --- /dev/null +++ b/android/src/main/res/drawable/ic_outline_wb_sunny_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_rewind_30.xml b/android/src/main/res/drawable/ic_rewind_30.xml new file mode 100644 index 0000000..768d3f5 --- /dev/null +++ b/android/src/main/res/drawable/ic_rewind_30.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/src/main/res/drawable/icon_font_decrease.png b/android/src/main/res/drawable/icon_font_decrease.png new file mode 100644 index 0000000000000000000000000000000000000000..7330622b2367d1446b69dee0652961e6b57dd620 GIT binary patch literal 676 zcmV;V0$crwP)pzR|j>eahS%%{wqVNL{w5u1dMv$-+%?rKAKQR5;g+!)OYj> zFyYxVK-MvJ3JlX2!WTu|KnfwLLVC>mt^tpMXToPZk(MOPQQyhQzjZr+Wv~7<>m;lX z5{B+2uWeGa4Qh)?5z;4w{m}cqBicrkwW!lYcsjoaws`IPaKGtwb6OuF4A~n^d}f7D zpQa$~kUlQ#TTOg!3;U#!7IiYh)Ac)Xbz=oW@OTZc6oL4ss^x$^9l=KmX9O1L}21;#il#;C>;7n>f!x9taDQ-N`I6E3($cqaT8F+=(^ z;URbz_*r0_m%ww+&M4`VQKp9SE!+<0#aN}wt$PUtz9alAr(Jg8?r(q0C`m}HkRB8E z16(>;lzHGu&K!k^m1zwEUp)I}gfXuJ-C_>IFbu;m48t%CLxn%}=G3(qEC3k*0000< KMNUMnLSTZj+B(Ak literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable/icon_font_increase.png b/android/src/main/res/drawable/icon_font_increase.png new file mode 100644 index 0000000000000000000000000000000000000000..5192b45195b80125dd472d350062771e11d2023e GIT binary patch literal 599 zcmV-d0;v6oP)rui$6o%n%92-arv|tOgKnt`$3$}nI#1f!{Sb~%gN{BzqaYER0ve&b_&b~7l6>?$8 z10h79)oQg`EdY22FbUI1T6mhK&8f7$ArE+v7Ori;Z;%H|(~es~tO36S4#c)YzYWLnBD<=ILu#FCeA&6_oOxJT1a!8>6pvCwx3wnLYm`wUHx2l zOz>D#GVQ@}asSb9|1>Z6Pz|Ix&gaEE9W&1~FaK8!q&d!Ci{J07zipN;wh`t%*RAF| zG4nmJE}irN_J09P6<}(jlCwAl5d;Bpt$;s)}_eXUdzkW-&NZ z-mGnkMVk9?;KG?Ip0#um@5-6-VyzYTHBG9CWo=Uo(%Q_jvX(ffnnnO!u05RNOr{Ij z?rY%2IbOGU3XNO;9{i=6WJ#Q3eUW|w45*PCd-ed>?TelA>Po zIIO{$;-1(i);9T)*5FKW7u>cUN7OVpQ`W3grOBDHVr`QTX=zZgIa66#bN`Hzf-@zl zx}+p6ot&q>YJV2Cy-P(}^WAw}7S=g90|1fbzBe0Ekj}}O1}w6+S&s|poSZ3D*4a1% lSbn%*S!+CwR;$%g?FaX9IuqORrYHaa002ovPDHLkV1j4s9tr>e literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable/icon_overflow.png b/android/src/main/res/drawable/icon_overflow.png new file mode 100644 index 0000000000000000000000000000000000000000..9fe1da8c2790c7c08206fdbf432353cd0ae2fa92 GIT binary patch literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkP*AeO zHKHUqKdq!Zu_%?nF(p4KRlzeiF+DXXH8G{K@MNkD0|TS5r;B4q#jQ7YPV+W7h&Tiq ziX?IB-IMd)$O1%O2meL~FwIps!q;{}|G8R9*klEDW5u*BQpZ0odb_KNg@J(sXas`- z0|N^~0|SEq0|OI-!~eDSe)GDynDhER{NkTxw+2H2!?tyH2W+=pzAAc7_S?UQTKgw# zWMu^DePCMB?|P(K>Rs#Q-)CwVBd-1VFnjYlhG>Yn2+Otvx&JWRazEppmZfMSHxtPE z1KBV1x;vhm+~HczKl3J2#5JCPKk2g5@4w87o&a+g)BpyD+0Q~+L>U|q2Etum@NORy Z4}SW literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable/rbtn_selector.xml b/android/src/main/res/drawable/rbtn_selector.xml new file mode 100644 index 0000000..f6f2850 --- /dev/null +++ b/android/src/main/res/drawable/rbtn_selector.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/rbtn_textcolor_selector.xml b/android/src/main/res/drawable/rbtn_textcolor_selector.xml new file mode 100644 index 0000000..c0c7414 --- /dev/null +++ b/android/src/main/res/drawable/rbtn_textcolor_selector.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/repfr.png b/android/src/main/res/drawable/repfr.png new file mode 100644 index 0000000000000000000000000000000000000000..26fddf5fd48070f27965da14844feb0a9b1a2cb7 GIT binary patch literal 22697 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 AmH+?% literal 0 HcmV?d00001 diff --git a/android/src/main/res/drawable/selector_blue.xml b/android/src/main/res/drawable/selector_blue.xml new file mode 100644 index 0000000..2f45893 --- /dev/null +++ b/android/src/main/res/drawable/selector_blue.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/selector_green.xml b/android/src/main/res/drawable/selector_green.xml new file mode 100644 index 0000000..1915fe4 --- /dev/null +++ b/android/src/main/res/drawable/selector_green.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/selector_purple.xml b/android/src/main/res/drawable/selector_purple.xml new file mode 100644 index 0000000..9261840 --- /dev/null +++ b/android/src/main/res/drawable/selector_purple.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/selector_red.xml b/android/src/main/res/drawable/selector_red.xml new file mode 100644 index 0000000..5917e38 --- /dev/null +++ b/android/src/main/res/drawable/selector_red.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/selector_yellow.xml b/android/src/main/res/drawable/selector_yellow.xml new file mode 100644 index 0000000..05c02ac --- /dev/null +++ b/android/src/main/res/drawable/selector_yellow.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/activity_epub.xml b/android/src/main/res/layout/activity_epub.xml new file mode 100644 index 0000000..7d5f6b8 --- /dev/null +++ b/android/src/main/res/layout/activity_epub.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/android/src/main/res/layout/activity_main.xml b/android/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b9036b0 --- /dev/null +++ b/android/src/main/res/layout/activity_main.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/activity_reader.xml b/android/src/main/res/layout/activity_reader.xml new file mode 100644 index 0000000..6815103 --- /dev/null +++ b/android/src/main/res/layout/activity_reader.xml @@ -0,0 +1,6 @@ + + + diff --git a/android/src/main/res/layout/add_catalog_dialog.xml b/android/src/main/res/layout/add_catalog_dialog.xml new file mode 100644 index 0000000..41a8d6e --- /dev/null +++ b/android/src/main/res/layout/add_catalog_dialog.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/filter_row.xml b/android/src/main/res/layout/filter_row.xml new file mode 100644 index 0000000..8578df7 --- /dev/null +++ b/android/src/main/res/layout/filter_row.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/android/src/main/res/layout/filter_window.xml b/android/src/main/res/layout/filter_window.xml new file mode 100644 index 0000000..1249195 --- /dev/null +++ b/android/src/main/res/layout/filter_window.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/android/src/main/res/layout/fragment_about.xml b/android/src/main/res/layout/fragment_about.xml new file mode 100644 index 0000000..2ca28f7 --- /dev/null +++ b/android/src/main/res/layout/fragment_about.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/fragment_audiobook.xml b/android/src/main/res/layout/fragment_audiobook.xml new file mode 100644 index 0000000..4d12f01 --- /dev/null +++ b/android/src/main/res/layout/fragment_audiobook.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/layout/fragment_bookshelf.xml b/android/src/main/res/layout/fragment_bookshelf.xml new file mode 100644 index 0000000..29f2d95 --- /dev/null +++ b/android/src/main/res/layout/fragment_bookshelf.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/fragment_catalog.xml b/android/src/main/res/layout/fragment_catalog.xml new file mode 100644 index 0000000..7aa3d49 --- /dev/null +++ b/android/src/main/res/layout/fragment_catalog.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/fragment_catalog_feed_list.xml b/android/src/main/res/layout/fragment_catalog_feed_list.xml new file mode 100644 index 0000000..1f5d484 --- /dev/null +++ b/android/src/main/res/layout/fragment_catalog_feed_list.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/fragment_drm_management.xml b/android/src/main/res/layout/fragment_drm_management.xml new file mode 100644 index 0000000..821e76a --- /dev/null +++ b/android/src/main/res/layout/fragment_drm_management.xml @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +