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 0000000..6dfe3af Binary files /dev/null and b/android/src/main/res/drawable/cnl.png differ diff --git a/android/src/main/res/drawable/cover.png b/android/src/main/res/drawable/cover.png new file mode 100644 index 0000000..c7f6957 Binary files /dev/null and b/android/src/main/res/drawable/cover.png differ diff --git a/android/src/main/res/drawable/ic_add_white_24dp.xml b/android/src/main/res/drawable/ic_add_white_24dp.xml new file mode 100644 index 0000000..5595783 --- /dev/null +++ b/android/src/main/res/drawable/ic_add_white_24dp.xml @@ -0,0 +1,9 @@ + + + \ 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 0000000..7330622 Binary files /dev/null and b/android/src/main/res/drawable/icon_font_decrease.png differ 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 0000000..5192b45 Binary files /dev/null and b/android/src/main/res/drawable/icon_font_increase.png differ 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 0000000..9fe1da8 Binary files /dev/null and b/android/src/main/res/drawable/icon_overflow.png differ 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 0000000..26fddf5 Binary files /dev/null and b/android/src/main/res/drawable/repfr.png differ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +