feat: Google Drive Backup (#872)
* setup google signin * minify build scripts, add google signin modal * feat: Drive backup * feat: drive restore * fix: remote backup * upgrade react native to stable version + less build configs * fix: path separator. feat: sync backup between 2 methods * legacy backup * fix: expo speech
This commit is contained in:
@@ -0,0 +1 @@
|
||||
18
|
||||
+1
-1
@@ -19,7 +19,7 @@ git clone https://github.com/<your-account-name>/lnreader.git
|
||||
cd lnreader
|
||||
|
||||
# install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
npm install
|
||||
|
||||
# build the apk (the built apk will be found in ~/lnreader/android/app/build/outputs/apk/release/)
|
||||
npm run buildRelease
|
||||
|
||||
+70
-139
@@ -1,129 +1,87 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
import com.android.build.OutputFile
|
||||
|
||||
/**
|
||||
* The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets
|
||||
* and bundleReleaseJsAndAssets).
|
||||
* These basically call `react-native bundle` with the correct arguments during the Android build
|
||||
* cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the
|
||||
* bundle directly from the development server. Below you can see all the possible configurations
|
||||
* and their defaults. If you decide to add a configuration block, make sure to add it before the
|
||||
* `apply from: "../../node_modules/react-native/react.gradle"` line.
|
||||
*
|
||||
* project.ext.react = [
|
||||
* // the name of the generated asset file containing your JS bundle
|
||||
* bundleAssetName: "index.android.bundle",
|
||||
*
|
||||
* // the entry file for bundle generation. If none specified and
|
||||
* // "index.android.js" exists, it will be used. Otherwise "index.js" is
|
||||
* // default. Can be overridden with ENTRY_FILE environment variable.
|
||||
* entryFile: "index.android.js",
|
||||
*
|
||||
* // https://reactnative.dev/docs/performance#enable-the-ram-format
|
||||
* bundleCommand: "ram-bundle",
|
||||
*
|
||||
* // whether to bundle JS and assets in debug mode
|
||||
* bundleInDebug: false,
|
||||
*
|
||||
* // whether to bundle JS and assets in release mode
|
||||
* bundleInRelease: true,
|
||||
*
|
||||
* // whether to bundle JS and assets in another build variant (if configured).
|
||||
* // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants
|
||||
* // The configuration property can be in the following formats
|
||||
* // 'bundleIn${productFlavor}${buildType}'
|
||||
* // 'bundleIn${buildType}'
|
||||
* // bundleInFreeDebug: true,
|
||||
* // bundleInPaidRelease: true,
|
||||
* // bundleInBeta: true,
|
||||
*
|
||||
* // whether to disable dev mode in custom build variants (by default only disabled in release)
|
||||
* // for example: to disable dev mode in the staging build type (if configured)
|
||||
* devDisabledInStaging: true,
|
||||
* // The configuration property can be in the following formats
|
||||
* // 'devDisabledIn${productFlavor}${buildType}'
|
||||
* // 'devDisabledIn${buildType}'
|
||||
*
|
||||
* // the root of your project, i.e. where "package.json" lives
|
||||
* root: "../../",
|
||||
*
|
||||
* // where to put the JS bundle asset in debug mode
|
||||
* jsBundleDirDebug: "$buildDir/intermediates/assets/debug",
|
||||
*
|
||||
* // where to put the JS bundle asset in release mode
|
||||
* jsBundleDirRelease: "$buildDir/intermediates/assets/release",
|
||||
*
|
||||
* // where to put drawable resources / React Native assets, e.g. the ones you use via
|
||||
* // require('./image.png')), in debug mode
|
||||
* resourcesDirDebug: "$buildDir/intermediates/res/merged/debug",
|
||||
*
|
||||
* // where to put drawable resources / React Native assets, e.g. the ones you use via
|
||||
* // require('./image.png')), in release mode
|
||||
* resourcesDirRelease: "$buildDir/intermediates/res/merged/release",
|
||||
*
|
||||
* // by default the gradle tasks are skipped if none of the JS files or assets change; this means
|
||||
* // that we don't look at files in android/ or ios/ to determine whether the tasks are up to
|
||||
* // date; if you have any other folders that you want to ignore for performance reasons (gradle
|
||||
* // indexes the entire tree), add them here. Alternatively, if you have JS files in android/
|
||||
* // for example, you might want to remove it from here.
|
||||
* inputExcludes: ["android/**", "ios/**"],
|
||||
*
|
||||
* // override which node gets called and with what additional arguments
|
||||
* nodeExecutableAndArgs: ["node"],
|
||||
*
|
||||
* // supply additional arguments to the packager
|
||||
* extraPackagerArgs: []
|
||||
* ]
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '..'
|
||||
// root = file("../")
|
||||
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
|
||||
// reactNativeDir = file("../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen
|
||||
// codegenDir = file("../node_modules/react-native-codegen")
|
||||
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
|
||||
// cliFile = file("../node_modules/react-native/cli.js")
|
||||
|
||||
project.ext.react = [
|
||||
entryFile: "index.js",
|
||||
enableHermes: true, // clean and rebuild if changing
|
||||
hermesCommand: '../../node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc',
|
||||
]
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
apply from: "../../node_modules/react-native/react.gradle"
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
//
|
||||
// The command to run when bundling. By default is 'bundle'
|
||||
// bundleCommand = "ram-bundle"
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/index.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true to create two separate APKs instead of one:
|
||||
* - An APK that only works on ARM devices
|
||||
* - An APK that only works on x86 devices
|
||||
* The advantage is the size of the APK is reduced by about 4MB.
|
||||
* Upload all the APKs to the Play Store and people will download
|
||||
* the correct one based on the CPU architecture of their device.
|
||||
* Set this to true to create four separate APKs instead of one,
|
||||
* one for each native architecture. This is useful if you don't
|
||||
* use App Bundles (https://developer.android.com/guide/app-bundle/)
|
||||
* and want to have separate APKs to upload to the Play Store.
|
||||
*/
|
||||
def enableSeparateBuildPerCPUArchitecture = false
|
||||
|
||||
/**
|
||||
* Run Proguard to shrink the Java bytecode in release builds.
|
||||
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||
*/
|
||||
def enableProguardInReleaseBuilds = false
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore.
|
||||
* The preferred build flavor of JavaScriptCore (JSC).
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'org.webkit:android-jsc:+'
|
||||
|
||||
/**
|
||||
* Whether to enable the Hermes VM.
|
||||
*
|
||||
* This should be set on project.ext.react and mirrored here. If it is not set
|
||||
* on project.ext.react, JavaScript will not be compiled to Hermes Bytecode
|
||||
* and the benefits of using Hermes will therefore be sharply reduced.
|
||||
*/
|
||||
def enableHermes = project.ext.react.get("enableHermes", false);
|
||||
|
||||
/**
|
||||
* Architectures to build native code for.
|
||||
* Private function to get the list of Native Architectures you want to build.
|
||||
* This reads the value from reactNativeArchitectures in your gradle.properties
|
||||
* file and works together with the --active-arch-only flag of react-native run-android.
|
||||
*/
|
||||
def reactNativeArchitectures() {
|
||||
def value = project.getProperties().get("reactNativeArchitectures")
|
||||
@@ -140,7 +98,7 @@ android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace "com.rajarsheechatterjee.LNReader"
|
||||
defaultConfig {
|
||||
applicationId 'com.rajarsheechatterjee.LNReader'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
@@ -148,7 +106,6 @@ android {
|
||||
// Generated version code. Supports versions up to 1024.1024.2048
|
||||
versionCode ((((versionMajor << 10) | versionMinor) << 11) | versionPatch)
|
||||
versionName "$versionMajor.$versionMinor.$versionPatch"
|
||||
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
@@ -196,17 +153,21 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
||||
//noinspection GradleDynamicVersion
|
||||
implementation "com.facebook.react:react-native:+" // From node_modules
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
implementation 'com.facebook.soloader:soloader:0.10.5+'
|
||||
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
||||
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.squareup.okhttp3', module:'okhttp'
|
||||
}
|
||||
|
||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
|
||||
|
||||
implementation project(':react-native-lottie-splash-screen')
|
||||
implementation project(':react-native-fs')
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
@@ -231,41 +192,11 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.fbjni'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
exclude group:'com.squareup.okhttp3', module:'okhttp'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
}
|
||||
|
||||
if (enableHermes) {
|
||||
//noinspection GradleDynamicVersion
|
||||
implementation("com.facebook.react:hermes-engine:+") { // From node_modules
|
||||
exclude group:'com.facebook.fbjni'
|
||||
}
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
|
||||
// Run this once to be able to run the application with BUCK
|
||||
// puts all compile dependencies into folder libs for BUCK to use
|
||||
task copyDownloadableDepsToLibs(type: Copy) {
|
||||
from configurations.implementation
|
||||
into 'libs'
|
||||
}
|
||||
|
||||
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
|
||||
applyNativeModulesAppBuildGradle(project)
|
||||
|
||||
def isNewArchitectureEnabled() {
|
||||
// To opt-in for the New Architecture, you can either:
|
||||
// - Set `newArchEnabled` to true inside the `gradle.properties` file
|
||||
// - Invoke gradle with `-newArchEnabled=true`
|
||||
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
|
||||
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
|
||||
}
|
||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Helper definitions to glob .aar and .jar targets"""
|
||||
|
||||
def create_aar_targets(aarfiles):
|
||||
for aarfile in aarfiles:
|
||||
name = "aars__" + aarfile[aarfile.rindex("/") + 1:aarfile.rindex(".aar")]
|
||||
lib_deps.append(":" + name)
|
||||
android_prebuilt_aar(
|
||||
name = name,
|
||||
aar = aarfile,
|
||||
)
|
||||
|
||||
def create_jar_targets(jarfiles):
|
||||
for jarfile in jarfiles:
|
||||
name = "jars__" + jarfile[jarfile.rindex("/") + 1:jarfile.rindex(".jar")]
|
||||
lib_deps.append(":" + name)
|
||||
prebuilt_jar(
|
||||
name = name,
|
||||
binary_jar = jarfile,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "1093792259511",
|
||||
"project_id": "lnreader-test",
|
||||
"storage_bucket": "lnreader-test.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1093792259511:android:aab0078285720642369c32",
|
||||
"android_client_info": {
|
||||
"package_name": "com.rajarsheechatterjee.LNReader"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "1093792259511-9kftu28l4ku1k3cco7pn83jbrlsc4fr5.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.rajarsheechatterjee.LNReader",
|
||||
"certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "1093792259511-ml5ko1gmnvihqb2j9nnteehoeom2umnt.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDuumsGzm6iE4u7E8pkk8FcNH9wL5GzoI8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "1093792259511-ml5ko1gmnvihqb2j9nnteehoeom2umnt.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
Vendored
-2
@@ -14,8 +14,6 @@
|
||||
-keep class com.facebook.jni.** { *; }
|
||||
-keep public class com.dylanvann.fastimage.* {*;}
|
||||
-keep public class com.dylanvann.fastimage.** {*;}
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep public class * extends com.bumptech.glide.module.AppGlideModule
|
||||
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
|
||||
+10
-5
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
|
||||
* directory of this source tree.
|
||||
*/
|
||||
package com.lnreader;
|
||||
package com.rajarsheechatterjee.LNReader;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
||||
@@ -17,7 +17,6 @@ import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
|
||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
||||
import com.facebook.react.ReactInstanceEventListener;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
@@ -25,15 +24,20 @@ import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.modules.network.NetworkingModule;
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
/**
|
||||
* Class responsible of loading Flipper inside your React Native application. This is the debug
|
||||
* flavor of it. Here you can add your own plugins and customize the Flipper setup.
|
||||
*/
|
||||
public class ReactNativeFlipper {
|
||||
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
||||
if (FlipperUtils.shouldEnableFlipper(context)) {
|
||||
final FlipperClient client = AndroidFlipperClient.getInstance(context);
|
||||
|
||||
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
|
||||
client.addPlugin(new ReactFlipperPlugin());
|
||||
client.addPlugin(new DatabasesFlipperPlugin(context));
|
||||
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
|
||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
||||
|
||||
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
|
||||
NetworkingModule.setCustomClientBuilder(
|
||||
new NetworkingModule.CustomClientBuilder() {
|
||||
@@ -44,6 +48,7 @@ public class ReactNativeFlipper {
|
||||
});
|
||||
client.addPlugin(networkFlipperPlugin);
|
||||
client.start();
|
||||
|
||||
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
|
||||
// Hence we run if after all native modules have been initialized
|
||||
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
|
||||
@@ -67,4 +72,4 @@ public class ReactNativeFlipper {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.rajarsheechatterjee.LNReader">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Internet -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
@@ -11,7 +11,8 @@ import android.view.WindowManager;
|
||||
import expo.modules.ReactActivityDelegateWrapper;
|
||||
import com.facebook.react.ReactActivity;
|
||||
import com.facebook.react.ReactActivityDelegate;
|
||||
import com.facebook.react.ReactRootView;
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate;
|
||||
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListener;
|
||||
|
||||
import org.devio.rn.splashscreen.SplashScreen;
|
||||
@@ -76,29 +77,13 @@ public class MainActivity extends ReactActivity {
|
||||
|
||||
@Override
|
||||
protected ReactActivityDelegate createReactActivityDelegate() {
|
||||
return new ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
new MainActivityDelegate(this, getMainComponentName()
|
||||
return new ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, new DefaultReactActivityDelegate(
|
||||
this,
|
||||
getMainComponentName(),
|
||||
// If you opted-in for the New Architecture, we enable the Fabric Renderer.
|
||||
DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
|
||||
// If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
|
||||
DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
|
||||
));
|
||||
}
|
||||
|
||||
public static class MainActivityDelegate extends ReactActivityDelegate {
|
||||
public MainActivityDelegate(ReactActivity activity, String mainComponentName) {
|
||||
super(activity, mainComponentName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ReactRootView createRootView() {
|
||||
ReactRootView reactRootView = new ReactRootView(getContext());
|
||||
// If you opted-in for the New Architecture, we enable the Fabric Renderer.
|
||||
reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED);
|
||||
return reactRootView;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isConcurrentRootEnabled() {
|
||||
// If you opted-in for the New Architecture, we enable Concurrent Root (i.e. React 18).
|
||||
// More on this on https://reactjs.org/blog/2022/03/29/react-v18.html
|
||||
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package com.rajarsheechatterjee.LNReader;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.facebook.react.PackageList;
|
||||
import com.facebook.react.ReactApplication;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
import com.facebook.react.ReactNativeHost;
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
|
||||
import com.rajarsheechatterjee.NavigationBarColor.NavigationBarColorPackage;
|
||||
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListenerPackage;
|
||||
import com.rajarsheechatterjee.EpubParser.EpubParserPackage;
|
||||
@@ -18,15 +19,12 @@ import com.rajarsheechatterjee.EpubParser.EpubParserPackage;
|
||||
import expo.modules.ApplicationLifecycleDispatcher;
|
||||
import expo.modules.ReactNativeHostWrapper;
|
||||
|
||||
import com.facebook.react.bridge.JSIModulePackage;
|
||||
import com.swmansion.reanimated.ReanimatedJSIModulePackage;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.List;
|
||||
|
||||
public class MainApplication extends Application implements ReactApplication {
|
||||
|
||||
private final ReactNativeHost mReactNativeHost =
|
||||
new ReactNativeHostWrapper(this, new ReactNativeHost(this) {
|
||||
new ReactNativeHostWrapper(this, new DefaultReactNativeHost(this) {
|
||||
@Override
|
||||
public boolean getUseDeveloperSupport() {
|
||||
return BuildConfig.DEBUG;
|
||||
@@ -47,11 +45,14 @@ public class MainApplication extends Application implements ReactApplication {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected JSIModulePackage getJSIModulePackage() {
|
||||
return new ReanimatedJSIModulePackage();
|
||||
protected boolean isNewArchEnabled() {
|
||||
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
|
||||
}
|
||||
}
|
||||
);
|
||||
@Override
|
||||
protected Boolean isHermesEnabled() {
|
||||
return BuildConfig.IS_HERMES_ENABLED;
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
public ReactNativeHost getReactNativeHost() {
|
||||
@@ -62,33 +63,12 @@ public class MainApplication extends Application implements ReactApplication {
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
SoLoader.init(this, /* native exopackage */ false);
|
||||
|
||||
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this);
|
||||
}
|
||||
|
||||
private static void initializeFlipper(
|
||||
Context context, ReactInstanceManager reactInstanceManager) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
/*
|
||||
* We use reflection here to pick up the class that initializes Flipper,
|
||||
* since Flipper library is not available in release mode
|
||||
*/
|
||||
Class<?> aClass = Class.forName("com.rajarsheechatterjee.LNReader.ReactNativeFlipper");
|
||||
aClass
|
||||
.getMethod("initializeFlipper", Context.class, ReactInstanceManager.class)
|
||||
.invoke(null, context, reactInstanceManager);
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
} catch (NoSuchMethodException e) {
|
||||
e.printStackTrace();
|
||||
} catch (IllegalAccessException e) {
|
||||
e.printStackTrace();
|
||||
} catch (InvocationTargetException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
||||
DefaultNewArchitectureEntryPoint.load();
|
||||
}
|
||||
ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
|
||||
* directory of this source tree.
|
||||
*/
|
||||
package com.rajarsheechatterjee.LNReader;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
|
||||
/**
|
||||
* Class responsible of loading Flipper inside your React Native application. This is the release
|
||||
* flavor of it so it's empty as we don't want to load Flipper.
|
||||
*/
|
||||
public class ReactNativeFlipper {
|
||||
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
||||
// Do nothing as we don't want to initialize Flipper on Release.
|
||||
}
|
||||
}
|
||||
+6
-52
@@ -4,66 +4,20 @@ import org.apache.tools.ant.taskdefs.condition.Os
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "31.0.0"
|
||||
buildToolsVersion = "33.0.0"
|
||||
minSdkVersion = 21
|
||||
compileSdkVersion = 31
|
||||
targetSdkVersion = 31
|
||||
compileSdkVersion = 33
|
||||
targetSdkVersion = 33
|
||||
|
||||
if (System.properties['os.arch'] == "aarch64") {
|
||||
// For M1 Users we need to use the NDK 24 which added support for aarch64
|
||||
ndkVersion = "24.0.8215888"
|
||||
} else {
|
||||
// Otherwise we default to the side-by-side NDK version from AGP.
|
||||
ndkVersion = "21.4.7075529"
|
||||
}
|
||||
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
|
||||
ndkVersion = "23.1.7779620"
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:7.1.2")
|
||||
classpath("com.android.tools.build:gradle:7.4.1")
|
||||
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||
classpath("de.undercouch:gradle-download-task:5.0.1")
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
exclusiveContent {
|
||||
// We get React Native's Android binaries exclusively through npm,
|
||||
// from a local Maven repo inside node_modules/react-native/.
|
||||
// (The use of exclusiveContent prevents looking elsewhere like Maven Central
|
||||
// and potentially getting a wrong version.)
|
||||
filter {
|
||||
includeGroup "com.facebook.react"
|
||||
}
|
||||
forRepository {
|
||||
maven {
|
||||
url "$rootDir/../node_modules/react-native/android"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
url(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../android"))
|
||||
}
|
||||
maven {
|
||||
// Android JSC is installed from npm
|
||||
url(new File(["node", "--print", "require.resolve('jsc-android/package.json')"].execute(null, rootDir).text.trim(), "../dist"))
|
||||
}
|
||||
mavenCentral {
|
||||
// We don't want to fetch react-native from Maven Central as there are
|
||||
// older versions over there.
|
||||
content {
|
||||
excludeGroup "com.facebook.react"
|
||||
}
|
||||
}
|
||||
google()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ android.enableJetifier=true
|
||||
|
||||
# Version of flipper SDK to use with React Native
|
||||
FLIPPER_VERSION=0.125.0
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
@@ -40,10 +41,14 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
# are providing them.
|
||||
newArchEnabled=false
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=true
|
||||
|
||||
# Enable GIF support in React Native images (~200 B increase)
|
||||
expo.gif.enabled=false
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
expo.webp.enabled=false
|
||||
# Enable animated webp support (~3.4 MB increase)
|
||||
# Disabled by default because iOS doesn't support animated webp
|
||||
expo.webp.animated=false
|
||||
expo.webp.animated=false
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
rootProject.name = 'LNReader'
|
||||
|
||||
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
|
||||
applyNativeModulesSettingsGradle(settings)
|
||||
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
|
||||
|
||||
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle")
|
||||
useExpoModules()
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"notification": {
|
||||
"icon": "./assets/notification.png",
|
||||
"color": "#00adb5"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"expo-localization"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
presets: ['babel-preset-expo', 'module:metro-react-native-babel-preset'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
@@ -19,6 +19,7 @@ module.exports = function (api) {
|
||||
'@theme': './src/theme',
|
||||
'@navigators': './src/navigators',
|
||||
'@native': './src/native',
|
||||
'@api': './src/api',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
};
|
||||
Generated
+4364
-18580
File diff suppressed because it is too large
Load Diff
+37
-34
@@ -15,76 +15,79 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@gorhom/bottom-sheet": "^4.4.5",
|
||||
"@react-native-community/clipboard": "^1.5.1",
|
||||
"@react-native-community/slider": "4.2.3",
|
||||
"@react-native-community/slider": "4.4.2",
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
"@react-native-picker/picker": "2.4.9",
|
||||
"@react-native-google-signin/google-signin": "^11.0.0",
|
||||
"@react-native-picker/picker": "2.4.8",
|
||||
"@react-navigation/material-bottom-tabs": "^6.0.9",
|
||||
"@react-navigation/native": "^6.0.6",
|
||||
"@react-navigation/stack": "^6.0.11",
|
||||
"@reduxjs/toolkit": "^1.7.2",
|
||||
"@shopify/flash-list": "1.1.0",
|
||||
"@shopify/flash-list": "1.4.0",
|
||||
"cheerio": "^1.0.0-rc.10",
|
||||
"color": "^4.2.3",
|
||||
"dayjs": "^1.11.4",
|
||||
"expo": "^46.0.0",
|
||||
"expo-document-picker": "~10.3.0",
|
||||
"expo-file-system": "~14.1.0",
|
||||
"expo-haptics": "~11.3.0",
|
||||
"expo-keep-awake": "~10.2.0",
|
||||
"expo-linear-gradient": "~11.4.0",
|
||||
"expo-linking": "~3.2.4",
|
||||
"expo-localization": "~13.1.0",
|
||||
"expo-notifications": "~0.16.1",
|
||||
"expo-sqlite": "~10.3.0",
|
||||
"expo-web-browser": "~11.0.0",
|
||||
"expo": "^48.0.21",
|
||||
"expo-clipboard": "~4.1.2",
|
||||
"expo-document-picker": "~11.2.2",
|
||||
"expo-file-system": "~15.2.2",
|
||||
"expo-haptics": "~12.2.1",
|
||||
"expo-keep-awake": "~12.0.1",
|
||||
"expo-linear-gradient": "~12.1.2",
|
||||
"expo-linking": "~4.0.1",
|
||||
"expo-localization": "~14.1.1",
|
||||
"expo-notifications": "~0.18.1",
|
||||
"expo-speech": "~11.1.1",
|
||||
"expo-sqlite": "~11.1.1",
|
||||
"expo-web-browser": "~12.1.1",
|
||||
"i18n-js": "^3.8.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"qs": "^6.10.3",
|
||||
"react": "18.0.0",
|
||||
"react-native": "^0.69.6",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.71.14",
|
||||
"react-native-background-actions": "^2.6.5",
|
||||
"react-native-device-info": "^8.4.7",
|
||||
"react-native-easing-gradient": "^1.0.0",
|
||||
"react-native-error-boundary": "^1.1.16",
|
||||
"react-native-fast-image": "^8.5.11",
|
||||
"react-native-fast-image": "^8.6.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.5.0",
|
||||
"react-native-gesture-handler": "~2.9.0",
|
||||
"react-native-lottie-splash-screen": "^1.0.1",
|
||||
"react-native-mmkv": "2.5.1",
|
||||
"react-native-pager-view": "^6.2.0",
|
||||
"react-native-mmkv": "^2.11.0",
|
||||
"react-native-pager-view": "6.1.2",
|
||||
"react-native-paper": "^5.2.0",
|
||||
"react-native-reanimated": "~2.9.1",
|
||||
"react-native-safe-area-context": "4.3.1",
|
||||
"react-native-screens": "~3.15.0",
|
||||
"react-native-reanimated": "~2.14.4",
|
||||
"react-native-safe-area-context": "4.5.0",
|
||||
"react-native-screens": "~3.20.0",
|
||||
"react-native-shimmer-placeholder": "^2.0.9",
|
||||
"react-native-tab-view": "^3.1.1",
|
||||
"react-native-tts": "^4.1.0",
|
||||
"react-native-vector-icons": "^9.0.0",
|
||||
"react-native-webview": "11.23.0",
|
||||
"react-native-webview": "11.26.0",
|
||||
"react-redux": "^7.2.6",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sanitize-html": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@react-native-community/eslint-config": "^3.0.1",
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@react-native-community/eslint-config": "^3.2.0",
|
||||
"@tsconfig/react-native": "^2.0.2",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/i18n-js": "^3.8.2",
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/react-native": "~0.69.1",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/react-native-vector-icons": "^6.4.10",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
"@types/sanitize-html": "^2.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.2",
|
||||
"@typescript-eslint/parser": "^5.10.2",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-react-native": "^3.11.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-react-native": "^4.1.0",
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": "^12.3.7",
|
||||
"typescript": "^4.6.3"
|
||||
"metro-react-native-babel-preset": "^0.73.10",
|
||||
"prettier": "^2.4.1",
|
||||
"typescript": "4.8.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^17"
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/python -u
|
||||
|
||||
'''
|
||||
Copyright 2009, The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
'''
|
||||
|
||||
# Script to highlight adb logcat output for console
|
||||
# Originally written by Jeff Sharkey, http://jsharkey.org/
|
||||
# Piping detection and popen() added by other Android team members
|
||||
# Package filtering and output improvements by Jake Wharton, http://jakewharton.com
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
from subprocess import PIPE
|
||||
from ctypes import *
|
||||
from platform import win32_ver
|
||||
|
||||
__version__ = '2.1.0+Win10'
|
||||
|
||||
win_ver = win32_ver()[0]
|
||||
show_colors = (win_ver == '10' or win_ver == '') and sys.stdout.isatty()
|
||||
|
||||
LOG_LEVELS = 'VDIWEF'
|
||||
LOG_LEVELS_MAP = dict([(LOG_LEVELS[i], i) for i in range(len(LOG_LEVELS))])
|
||||
parser = argparse.ArgumentParser(description='Filter logcat by package name')
|
||||
parser.add_argument('package', nargs='*', help='Application package name(s)')
|
||||
parser.add_argument('-w', '--tag-width', metavar='N', dest='tag_width', type=int, default=23, help='Width of log tag')
|
||||
parser.add_argument('-l', '--min-level', dest='min_level', type=str, choices=LOG_LEVELS+LOG_LEVELS.lower(), default='V', help='Minimum level to be displayed')
|
||||
parser.add_argument('--color-gc', dest='color_gc', action='store_true', help='Color garbage collection')
|
||||
parser.add_argument('--always-display-tags', dest='always_tags', action='store_true',help='Always display the tag name')
|
||||
parser.add_argument('--current', dest='current_app', action='store_true',help='Filter logcat by current running app')
|
||||
parser.add_argument('-s', '--serial', dest='device_serial', help='Device serial number (adb -s option)')
|
||||
parser.add_argument('-d', '--device', dest='use_device', action='store_true', help='Use first device for log input (adb -d option)')
|
||||
parser.add_argument('-e', '--emulator', dest='use_emulator', action='store_true', help='Use first emulator for log input (adb -e option)')
|
||||
parser.add_argument('-c', '--clear', dest='clear_logcat', action='store_true', help='Clear the entire log before running')
|
||||
parser.add_argument('-t', '--tag', dest='tag', action='append', help='Filter output by specified tag(s)')
|
||||
parser.add_argument('-i', '--ignore-tag', dest='ignored_tag', action='append', help='Filter output by ignoring specified tag(s)')
|
||||
parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + __version__, help='Print the version number and exit')
|
||||
parser.add_argument('-a', '--all', dest='all', action='store_true', default=False, help='Print all log messages')
|
||||
parser.add_argument('-o', '--output', dest='output', type=str, default='', help='Output filename')
|
||||
parser.add_argument('-n', '--no-color', dest='no_color', action='store_true', help='Disable colors')
|
||||
parser.add_argument('-r', '--regex', dest='regex', type=str, help='Print only when matches REGEX and REGEX is regular expression logcat -e REGEX')
|
||||
|
||||
args = parser.parse_args()
|
||||
min_level = LOG_LEVELS_MAP[args.min_level.upper()]
|
||||
|
||||
if (args.no_color):
|
||||
show_colors = False
|
||||
|
||||
package = args.package
|
||||
if len(package) == 0:
|
||||
package.append('com.rajarsheechatterjee.LNReader')
|
||||
|
||||
# enabling Windows 10 VT100 support
|
||||
def enableVT100():
|
||||
STD_OUTPUT_HANDLE = -11
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4
|
||||
|
||||
stdout = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
|
||||
if stdout == -1:
|
||||
raise WinError()
|
||||
|
||||
mode = c_uint()
|
||||
if windll.kernel32.GetConsoleMode(stdout, byref(mode)) == 0:
|
||||
raise WinError()
|
||||
|
||||
mode.value = mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
|
||||
if windll.kernel32.SetConsoleMode(stdout, mode) == 0:
|
||||
raise WinError()
|
||||
|
||||
if (show_colors):
|
||||
enableVT100()
|
||||
|
||||
output_file = False
|
||||
if (args.output):
|
||||
output_file = open(args.output, 'a+')
|
||||
|
||||
base_adb_command = ['adb']
|
||||
if args.device_serial:
|
||||
base_adb_command.extend(['-s', args.device_serial])
|
||||
if args.use_device:
|
||||
base_adb_command.append('-d')
|
||||
if args.use_emulator:
|
||||
base_adb_command.append('-e')
|
||||
|
||||
if args.current_app:
|
||||
system_dump_command = base_adb_command + ["shell", "dumpsys", "activity", "activities"]
|
||||
system_dump = subprocess.Popen(system_dump_command, stdout=PIPE, stderr=PIPE).communicate()[0]
|
||||
running_package_name = re.search(".*TaskRecord.*A[= ]([^ ^}]*)", system_dump).group(1)
|
||||
package.append(running_package_name)
|
||||
|
||||
if len(package) == 0:
|
||||
args.all = True
|
||||
|
||||
# Store the names of packages for which to match all processes.
|
||||
catchall_package = list(filter(lambda package: package.find(":") == -1, package))
|
||||
# Store the name of processes to match exactly.
|
||||
named_processes = filter(lambda package: package.find(":") != -1, package)
|
||||
# Convert default process names from <package>: (cli notation) to <package> (android notation) in the exact names match group.
|
||||
named_processes = map(lambda package: package if package.find(":") != len(package) - 1 else package[:-1], named_processes)
|
||||
|
||||
header_size = args.tag_width + 1 + 3 + 1 # space, level, space
|
||||
|
||||
width = -1
|
||||
try:
|
||||
# Get the current terminal width
|
||||
import fcntl, termios, struct
|
||||
h, width = struct.unpack('hh', fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('hh', 0, 0)))
|
||||
except:
|
||||
pass
|
||||
|
||||
BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8)
|
||||
|
||||
RESET = '\033[0m'
|
||||
|
||||
def termcolor(fg=None, bg=None):
|
||||
codes = []
|
||||
if fg is not None: codes.append('3%d' % fg)
|
||||
if bg is not None: codes.append('10%d' % bg)
|
||||
return '\033[%sm' % ';'.join(codes) if codes else ''
|
||||
|
||||
def colorize(message, fg=None, bg=None):
|
||||
return termcolor(fg, bg) + message + RESET
|
||||
|
||||
def indent_wrap(message):
|
||||
if width == -1:
|
||||
return message
|
||||
message = message.replace('\t', ' ')
|
||||
wrap_area = width - header_size
|
||||
messagebuf = ''
|
||||
current = 0
|
||||
while current < len(message):
|
||||
next = min(current + wrap_area, len(message))
|
||||
messagebuf += message[current:next]
|
||||
if next < len(message):
|
||||
messagebuf += '\n'
|
||||
messagebuf += ' ' * header_size
|
||||
current = next
|
||||
return messagebuf
|
||||
|
||||
|
||||
LAST_USED = [RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN]
|
||||
KNOWN_TAGS = {
|
||||
'dalvikvm': WHITE,
|
||||
'Process': WHITE,
|
||||
'ActivityManager': WHITE,
|
||||
'ActivityThread': WHITE,
|
||||
'AndroidRuntime': CYAN,
|
||||
'jdwp': WHITE,
|
||||
'StrictMode': WHITE,
|
||||
'DEBUG': YELLOW,
|
||||
}
|
||||
|
||||
def allocate_color(tag):
|
||||
# this will allocate a unique format for the given tag
|
||||
# since we dont have very many colors, we always keep track of the LRU
|
||||
if tag not in KNOWN_TAGS:
|
||||
KNOWN_TAGS[tag] = LAST_USED[0]
|
||||
color = KNOWN_TAGS[tag]
|
||||
if color in LAST_USED:
|
||||
LAST_USED.remove(color)
|
||||
LAST_USED.append(color)
|
||||
return color
|
||||
|
||||
NO_COLOR = re.compile(r'\033\[.*?m')
|
||||
|
||||
def output(line):
|
||||
line_nocolor = '' if show_colors and not output_file else NO_COLOR.sub('', line)
|
||||
print(line if show_colors else line_nocolor)
|
||||
if (output_file):
|
||||
output_file.write(line_nocolor + '\n')
|
||||
|
||||
RULES = {
|
||||
# StrictMode policy violation; ~duration=319 ms: android.os.StrictMode$StrictModeDiskWriteViolation: policy=31 violation=1
|
||||
re.compile(r'^(StrictMode policy violation)(; ~duration=)(\d+ ms)')
|
||||
: r'%s\1%s\2%s\3%s' % (termcolor(RED), RESET, termcolor(YELLOW), RESET),
|
||||
}
|
||||
|
||||
# Only enable GC coloring if the user opted-in
|
||||
if args.color_gc:
|
||||
# GC_CONCURRENT freed 3617K, 29% free 20525K/28648K, paused 4ms+5ms, total 85ms
|
||||
key = re.compile(r'^(GC_(?:CONCURRENT|FOR_M?ALLOC|EXTERNAL_ALLOC|EXPLICIT) )(freed <?\d+.)(, \d+\% free \d+./\d+., )(paused \d+ms(?:\+\d+ms)?)')
|
||||
val = r'\1%s\2%s\3%s\4%s' % (termcolor(GREEN), RESET, termcolor(YELLOW), RESET)
|
||||
|
||||
RULES[key] = val
|
||||
|
||||
|
||||
TAGTYPES = {
|
||||
'V': colorize(' V ', fg=WHITE, bg=BLACK),
|
||||
'D': colorize(' D ', fg=BLACK, bg=BLUE),
|
||||
'I': colorize(' I ', fg=BLACK, bg=GREEN),
|
||||
'W': colorize(' W ', fg=BLACK, bg=YELLOW),
|
||||
'E': colorize(' E ', fg=BLACK, bg=RED),
|
||||
'F': colorize(' F ', fg=BLACK, bg=RED),
|
||||
}
|
||||
|
||||
PID_LINE = re.compile(r'^\w+\s+(\w+)\s+\w+\s+\w+\s+\w+\s+\w+\s+\w+\s+\w\s([\w|\.|\/]+)$')
|
||||
PID_START = re.compile(r'^.*: Start proc ([a-zA-Z0-9._:]+) for ([a-z]+ [^:]+): pid=(\d+) uid=(\d+) gids=(.*)$')
|
||||
PID_START_5_1 = re.compile(r'^.*: Start proc (\d+):([a-zA-Z0-9._:]+)/[a-z0-9]+ for (.*)$')
|
||||
PID_START_DALVIK = re.compile(r'^E/dalvikvm\(\s*(\d+)\): >>>>> ([a-zA-Z0-9._:]+) \[ userId:0 \| appId:(\d+) \]$')
|
||||
PID_KILL = re.compile(r'^Killing (\d+):([a-zA-Z0-9._:]+)/[^:]+: (.*)$')
|
||||
PID_LEAVE = re.compile(r'^No longer want ([a-zA-Z0-9._:]+) \(pid (\d+)\): .*$')
|
||||
PID_DEATH = re.compile(r'^Process ([a-zA-Z0-9._:]+) \(pid (\d+)\) has died.?$')
|
||||
LOG_LINE = re.compile(r'^([A-Z])/(.+?)\( *(\d+)\): (.*?)$')
|
||||
BUG_LINE = re.compile(r'.*nativeGetEnabledTags.*')
|
||||
BACKTRACE_LINE = re.compile(r'^#(.*?)pc\s(.*?)$')
|
||||
|
||||
adb_command = base_adb_command[:]
|
||||
adb_command.append('logcat')
|
||||
adb_command.extend(['-v', 'brief'])
|
||||
if args.regex:
|
||||
adb_command.extend(['-e',args.regex])
|
||||
|
||||
# Clear log before starting logcat
|
||||
if args.clear_logcat:
|
||||
adb_clear_command = list(adb_command)
|
||||
adb_clear_command.append('-c')
|
||||
adb_clear = subprocess.Popen(adb_clear_command)
|
||||
|
||||
while adb_clear.poll() is None:
|
||||
pass
|
||||
|
||||
# This is a ducktype of the subprocess.Popen object
|
||||
class FakeStdinProcess():
|
||||
def __init__(self):
|
||||
self.stdout = sys.stdin
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
if sys.stdin.isatty():
|
||||
adb = subprocess.Popen(adb_command, stdin=PIPE, stdout=PIPE)
|
||||
else:
|
||||
adb = FakeStdinProcess()
|
||||
pids = set()
|
||||
last_tag = None
|
||||
app_pid = None
|
||||
|
||||
def match_packages(token):
|
||||
if len(package) == 0:
|
||||
return True
|
||||
if token in named_processes:
|
||||
return True
|
||||
index = token.find(':')
|
||||
return (token in catchall_package) if index == -1 else (token[:index] in catchall_package)
|
||||
|
||||
def parse_death(tag, message):
|
||||
if tag != 'ActivityManager':
|
||||
return None, None
|
||||
kill = PID_KILL.match(message)
|
||||
if kill:
|
||||
pid = kill.group(1)
|
||||
package_line = kill.group(2)
|
||||
if match_packages(package_line) and pid in pids:
|
||||
return pid, package_line
|
||||
leave = PID_LEAVE.match(message)
|
||||
if leave:
|
||||
pid = leave.group(2)
|
||||
package_line = leave.group(1)
|
||||
if match_packages(package_line) and pid in pids:
|
||||
return pid, package_line
|
||||
death = PID_DEATH.match(message)
|
||||
if death:
|
||||
pid = death.group(2)
|
||||
package_line = death.group(1)
|
||||
if match_packages(package_line) and pid in pids:
|
||||
return pid, package_line
|
||||
return None, None
|
||||
|
||||
def parse_start_proc(line):
|
||||
start = PID_START_5_1.match(line)
|
||||
if start is not None:
|
||||
line_pid, line_package, target = start.groups()
|
||||
return line_package, target, line_pid, '', ''
|
||||
start = PID_START.match(line)
|
||||
if start is not None:
|
||||
line_package, target, line_pid, line_uid, line_gids = start.groups()
|
||||
return line_package, target, line_pid, line_uid, line_gids
|
||||
start = PID_START_DALVIK.match(line)
|
||||
if start is not None:
|
||||
line_pid, line_package, line_uid = start.groups()
|
||||
return line_package, '', line_pid, line_uid, ''
|
||||
return None
|
||||
|
||||
def tag_in_tags_regex(tag, tags):
|
||||
return any(re.match(r'^' + t + r'$', tag) for t in map(str.strip, tags))
|
||||
|
||||
ps_command = base_adb_command + ['shell', 'ps']
|
||||
ps_pid = subprocess.Popen(ps_command, stdin=PIPE, stdout=PIPE, stderr=PIPE)
|
||||
while True:
|
||||
try:
|
||||
line = ps_pid.stdout.readline().decode('utf-8', 'replace').strip()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
if len(line) == 0:
|
||||
break
|
||||
|
||||
pid_match = PID_LINE.match(line)
|
||||
if pid_match is not None:
|
||||
pid = pid_match.group(1)
|
||||
proc = pid_match.group(2)
|
||||
if proc in catchall_package:
|
||||
seen_pids = True
|
||||
pids.add(pid)
|
||||
|
||||
while adb.poll() is None:
|
||||
try:
|
||||
line = adb.stdout.readline().decode('utf-8', 'replace').strip()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
if len(line) == 0:
|
||||
break
|
||||
|
||||
bug_line = BUG_LINE.match(line)
|
||||
if bug_line is not None:
|
||||
continue
|
||||
|
||||
log_line = LOG_LINE.match(line)
|
||||
if log_line is None:
|
||||
continue
|
||||
|
||||
level, tag, owner, message = log_line.groups()
|
||||
tag = tag.strip()
|
||||
start = parse_start_proc(line)
|
||||
if start:
|
||||
line_package, target, line_pid, line_uid, line_gids = start
|
||||
if match_packages(line_package):
|
||||
pids.add(line_pid)
|
||||
|
||||
app_pid = line_pid
|
||||
|
||||
linebuf = '\n'
|
||||
linebuf += colorize(' ' * (header_size - 1), bg=WHITE)
|
||||
linebuf += indent_wrap(' Process %s created for %s\n' % (line_package, target))
|
||||
linebuf += colorize(' ' * (header_size - 1), bg=WHITE)
|
||||
linebuf += ' PID: %s UID: %s GIDs: %s' % (line_pid, line_uid, line_gids)
|
||||
linebuf += '\n'
|
||||
output(linebuf)
|
||||
last_tag = None # Ensure next log gets a tag printed
|
||||
|
||||
dead_pid, dead_pname = parse_death(tag, message)
|
||||
if dead_pid:
|
||||
pids.remove(dead_pid)
|
||||
linebuf = '\n'
|
||||
linebuf += colorize(' ' * (header_size - 1), bg=RED)
|
||||
linebuf += ' Process %s (PID: %s) ended' % (dead_pname, dead_pid)
|
||||
linebuf += '\n'
|
||||
output(linebuf)
|
||||
last_tag = None # Ensure next log gets a tag printed
|
||||
|
||||
# Make sure the backtrace is printed after a native crash
|
||||
if tag == 'DEBUG':
|
||||
bt_line = BACKTRACE_LINE.match(message.lstrip())
|
||||
if bt_line is not None:
|
||||
message = message.lstrip()
|
||||
owner = app_pid
|
||||
|
||||
if not args.all and owner not in pids:
|
||||
continue
|
||||
if level in LOG_LEVELS_MAP and LOG_LEVELS_MAP[level] < min_level:
|
||||
continue
|
||||
if args.ignored_tag and tag_in_tags_regex(tag, args.ignored_tag):
|
||||
continue
|
||||
if args.tag and not tag_in_tags_regex(tag, args.tag):
|
||||
continue
|
||||
|
||||
linebuf = ''
|
||||
|
||||
if args.tag_width > 0:
|
||||
# right-align tag title and allocate color if needed
|
||||
if tag != last_tag or args.always_tags:
|
||||
last_tag = tag
|
||||
color = allocate_color(tag)
|
||||
tag = tag[-args.tag_width:].rjust(args.tag_width)
|
||||
linebuf += colorize(tag, fg=color)
|
||||
else:
|
||||
linebuf += ' ' * args.tag_width
|
||||
linebuf += ' '
|
||||
|
||||
# write out level colored edge
|
||||
if level in TAGTYPES:
|
||||
linebuf += TAGTYPES[level]
|
||||
else:
|
||||
linebuf += ' ' + level + ' '
|
||||
linebuf += ' '
|
||||
|
||||
# format tag message using rules
|
||||
for matcher in RULES:
|
||||
replace = RULES[matcher]
|
||||
message = matcher.sub(replace, message)
|
||||
|
||||
linebuf += indent_wrap(message)
|
||||
output(linebuf)
|
||||
|
||||
if (output_file):
|
||||
output_file.close()
|
||||
@@ -0,0 +1 @@
|
||||
export const PATH_SEPARATOR = '&&';
|
||||
@@ -0,0 +1,111 @@
|
||||
import { create, download, getJson, list } from './request';
|
||||
import { DriveCreateRequestData, DriveFile } from './types';
|
||||
|
||||
const LNREADER_DRIVE_MARK = '(Do not change this!) LNReader-Drive';
|
||||
|
||||
export const exists = async (
|
||||
fileName: string,
|
||||
isFolder?: boolean,
|
||||
parentId?: string,
|
||||
marked?: boolean,
|
||||
) => {
|
||||
let q = `name = '${fileName}' and trashed = false `;
|
||||
if (marked) {
|
||||
q += ` and fullText contains '${LNREADER_DRIVE_MARK}' `;
|
||||
}
|
||||
if (isFolder) {
|
||||
q += " and mimeType = 'application/vnd.google-apps.folder' ";
|
||||
}
|
||||
if (parentId) {
|
||||
q += ` and '${parentId}' in parents `;
|
||||
}
|
||||
const res = await list({ q });
|
||||
return res.files.find(file => file.name === fileName);
|
||||
};
|
||||
|
||||
export const makeDir = async (fileName: string, parentId?: string) => {
|
||||
const existedDir = await exists(fileName, true, parentId);
|
||||
if (existedDir) {
|
||||
return existedDir;
|
||||
}
|
||||
const data: DriveCreateRequestData = {
|
||||
metadata: {
|
||||
name: fileName,
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
description: LNREADER_DRIVE_MARK,
|
||||
},
|
||||
};
|
||||
if (parentId) {
|
||||
data.metadata.parents = [parentId];
|
||||
}
|
||||
return create(data);
|
||||
};
|
||||
|
||||
export const createFile = async (
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
content: string,
|
||||
parentId?: string,
|
||||
) => {
|
||||
const existedFile = await exists(fileName, false, parentId);
|
||||
if (existedFile) {
|
||||
return existedFile;
|
||||
}
|
||||
const data: DriveCreateRequestData = {
|
||||
metadata: {
|
||||
name: fileName,
|
||||
mimeType: mimeType,
|
||||
description: LNREADER_DRIVE_MARK,
|
||||
},
|
||||
content: content,
|
||||
};
|
||||
if (parentId) {
|
||||
data.metadata.parents = [parentId];
|
||||
}
|
||||
return create(data);
|
||||
};
|
||||
|
||||
export const getBackups = async (parentId: string, marked?: boolean) => {
|
||||
let q = `
|
||||
name contains '.backup' and trashed = false
|
||||
and mimeType = 'application/vnd.google-apps.folder' and '${parentId}' in parents
|
||||
`;
|
||||
if (marked) {
|
||||
q += ` and fullText contains '${LNREADER_DRIVE_MARK}' `;
|
||||
}
|
||||
return list({
|
||||
q,
|
||||
}).then(res => res.files);
|
||||
};
|
||||
|
||||
export const readDir = async (parentId: string, marked?: boolean) => {
|
||||
let fileList: DriveFile[] = [];
|
||||
let pageToken = '';
|
||||
let q = `
|
||||
trashed = false and mimeType != 'application/vnd.google-apps.folder'
|
||||
and '${parentId}' in parents
|
||||
`;
|
||||
if (marked) {
|
||||
q += ` and fullText contains '${LNREADER_DRIVE_MARK}' `;
|
||||
}
|
||||
let hasNextPage = true;
|
||||
while (hasNextPage) {
|
||||
const query = pageToken ? q + ` and pageToken = '${pageToken}'` : q;
|
||||
const { nextPageToken, files } = await list({ q: query });
|
||||
if (!nextPageToken) {
|
||||
hasNextPage = false;
|
||||
} else {
|
||||
pageToken = nextPageToken;
|
||||
}
|
||||
fileList = fileList.concat(files);
|
||||
}
|
||||
return fileList;
|
||||
};
|
||||
|
||||
export const readFile = async (file: DriveFile, type: 'json' | 'media') => {
|
||||
if (type === 'json') {
|
||||
return getJson(file.id);
|
||||
} else {
|
||||
return download(file);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { GoogleSignin } from '@react-native-google-signin/google-signin';
|
||||
import { downloadFile, mkdir, exists } from 'react-native-fs';
|
||||
import {
|
||||
DriveCreateRequestData,
|
||||
DriveFile,
|
||||
DriveReponse,
|
||||
DriveRequestParams,
|
||||
} from './types';
|
||||
import { AppDownloadFolder } from '@utils/constants/download';
|
||||
import { PATH_SEPARATOR } from '@api/constants';
|
||||
|
||||
const BASE_URL = 'https://www.googleapis.com/drive/v3/files';
|
||||
const MEDIA_UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3/files';
|
||||
|
||||
const buildParams = (params: DriveRequestParams) => {
|
||||
return Object.entries(params)
|
||||
.map(pair => pair.map(encodeURIComponent).join('='))
|
||||
.join('&');
|
||||
};
|
||||
|
||||
export const list = async (
|
||||
params: DriveRequestParams,
|
||||
): Promise<DriveReponse> => {
|
||||
const { accessToken } = await GoogleSignin.getTokens();
|
||||
|
||||
if (!params.fields) {
|
||||
params.fields =
|
||||
'nextPageToken, files(id, name, description, createdTime, parents)';
|
||||
}
|
||||
|
||||
const url = BASE_URL + '?' + buildParams(params);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}).then(res => res.json());
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
data: DriveCreateRequestData,
|
||||
): Promise<DriveFile> => {
|
||||
const { accessToken } = await GoogleSignin.getTokens();
|
||||
|
||||
const params: DriveRequestParams = {
|
||||
fields: 'id, name, description, createdTime, parents',
|
||||
uploadType: 'multipart',
|
||||
};
|
||||
const url =
|
||||
(data.content ? MEDIA_UPLOAD_URL : BASE_URL) + '?' + buildParams(params);
|
||||
let body: any;
|
||||
data.metadata.name = data.metadata.name.replace(/\//g, PATH_SEPARATOR);
|
||||
if (data.content) {
|
||||
body = new FormData();
|
||||
body.append('metadata', {
|
||||
string: JSON.stringify(data.metadata),
|
||||
type: 'application/json',
|
||||
});
|
||||
if (data.metadata.mimeType === 'application/json') {
|
||||
body.append('media', {
|
||||
string: data.content,
|
||||
type: data.metadata.mimeType,
|
||||
});
|
||||
} else {
|
||||
body.append('media', {
|
||||
name: data.metadata.name,
|
||||
uri: data.content,
|
||||
type: data.metadata.mimeType,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
body = JSON.stringify(data.metadata);
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
}).then(res => res.json());
|
||||
};
|
||||
|
||||
export const getJson = async (id: string) => {
|
||||
const { accessToken } = await GoogleSignin.getTokens();
|
||||
const url = BASE_URL + '/' + id + '?alt=media';
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}).then(res => res.json());
|
||||
};
|
||||
|
||||
export const download = async (file: DriveFile) => {
|
||||
const { accessToken } = await GoogleSignin.getTokens();
|
||||
const url = BASE_URL + '/' + file.id + '?alt=media';
|
||||
const regex = new RegExp(`${PATH_SEPARATOR}`, 'g');
|
||||
const filePath = AppDownloadFolder + '/' + file.name.replace(regex, '/');
|
||||
const dirPath = filePath.split('/').slice(0, -1).join('/');
|
||||
return exists(filePath).then(existed => {
|
||||
if (!existed) {
|
||||
return mkdir(dirPath).then(() => {
|
||||
return downloadFile({
|
||||
fromUrl: url,
|
||||
toFile: filePath,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface DriveFile {
|
||||
kind: string;
|
||||
mimeType?: string;
|
||||
parents: string[];
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdTime?: string;
|
||||
}
|
||||
|
||||
export interface DriveReponse {
|
||||
nextPageToken?: string;
|
||||
kind: string;
|
||||
incompleteSearch: boolean;
|
||||
files: DriveFile[];
|
||||
}
|
||||
|
||||
export interface DriveRequestParams {
|
||||
q?: string;
|
||||
orderBy?: string;
|
||||
pageSize?: number;
|
||||
fields?: string;
|
||||
pageToken?: string;
|
||||
uploadType?: string; // only for upload
|
||||
}
|
||||
|
||||
export interface DriveCreateRequestData {
|
||||
metadata: {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
description?: string;
|
||||
parents?: string[];
|
||||
};
|
||||
content?: string; // uri if upload file
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PATH_SEPARATOR } from '@api/constants';
|
||||
import { BackupPackage } from '@services/backup/types';
|
||||
import { AppDownloadFolder } from '@utils/constants/download';
|
||||
import { downloadFile, mkdir } from 'react-native-fs';
|
||||
|
||||
const commonHeaders = {
|
||||
'Connection': 'keep-alive',
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
};
|
||||
|
||||
export const upload = (host: string, data: BackupPackage) => {
|
||||
const url = host + '/' + 'upload';
|
||||
const body = new FormData();
|
||||
data.name = data.name.replace(/\//g, PATH_SEPARATOR);
|
||||
body.append('metadata', JSON.stringify(data));
|
||||
if (data.mimeType === 'application/json') {
|
||||
body.append('media', {
|
||||
string: data.content,
|
||||
type: data.mimeType,
|
||||
});
|
||||
} else {
|
||||
body.append('media', {
|
||||
name: data.name,
|
||||
uri: data.content,
|
||||
type: data.mimeType,
|
||||
});
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: commonHeaders,
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
export const list = (host: string, folderTree: string[]): Promise<string[]> => {
|
||||
const url = host + '/' + 'list';
|
||||
const body = new FormData();
|
||||
body.append('metadata', JSON.stringify({ folderTree }));
|
||||
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: commonHeaders,
|
||||
body,
|
||||
}).then(res => res.json());
|
||||
};
|
||||
|
||||
export const getJson = (host: string, folderTree: string[], name: string) => {
|
||||
const url = host + '/' + 'download' + '/' + folderTree.join('/') + '/' + name;
|
||||
return fetch(url, {
|
||||
headers: commonHeaders,
|
||||
}).then(res => res.json());
|
||||
};
|
||||
|
||||
export const download = async (
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
name: string,
|
||||
) => {
|
||||
const url = host + '/' + 'download' + '/' + folderTree.join('/') + '/' + name;
|
||||
const regex = new RegExp(`${PATH_SEPARATOR}`, 'g');
|
||||
const filePath = AppDownloadFolder + '/' + name.replace(regex, '/');
|
||||
const dirPath = filePath.split('/').slice(0, -1).join('/');
|
||||
await mkdir(dirPath).then(() => {
|
||||
return downloadFile({
|
||||
fromUrl: url,
|
||||
toFile: filePath,
|
||||
headers: commonHeaders,
|
||||
}).promise;
|
||||
});
|
||||
};
|
||||
|
||||
export const exists = (
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
name: string,
|
||||
): Promise<boolean> => {
|
||||
const url = host + '/' + 'exists';
|
||||
const body = new FormData();
|
||||
body.append('metadata', JSON.stringify({ folderTree, name }));
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: commonHeaders,
|
||||
body,
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => data.exists);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ interface ListItemProps {
|
||||
icon?: string;
|
||||
onPress?: () => void;
|
||||
theme: ThemeColors;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Section = ({ children }: { children: ReactNode }) => (
|
||||
@@ -35,12 +36,15 @@ const Item: React.FC<ListItemProps> = ({
|
||||
icon,
|
||||
onPress,
|
||||
theme,
|
||||
disabled,
|
||||
}) => (
|
||||
<PaperList.Item
|
||||
title={title}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
titleStyle={{ color: disabled ? theme.onSurfaceDisabled : theme.onSurface }}
|
||||
description={description}
|
||||
descriptionStyle={{ color: theme.onSurfaceVariant }}
|
||||
descriptionStyle={{
|
||||
color: disabled ? theme.onSurfaceDisabled : theme.onSurfaceVariant,
|
||||
}}
|
||||
left={
|
||||
icon
|
||||
? () => (
|
||||
@@ -52,6 +56,7 @@ const Item: React.FC<ListItemProps> = ({
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
rippleColor={theme.rippleColor}
|
||||
style={styles.listItemCtn}
|
||||
@@ -83,7 +88,14 @@ const Icon = ({ icon, theme }: { icon: string; theme: ThemeColors }) => (
|
||||
<PaperList.Icon color={theme.primary} icon={icon} style={{ margin: 0 }} />
|
||||
);
|
||||
|
||||
const ColorItem = ({ title, description, theme, onPress }) => (
|
||||
interface ColorItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
theme: ThemeColors;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const ColorItem = ({ title, description, theme, onPress }: ColorItemProps) => (
|
||||
<Pressable
|
||||
style={{
|
||||
padding: 16,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { noop } from 'lodash-es';
|
||||
import { Category } from '../types';
|
||||
import { BackupCategory, Category, NovelCategory } from '../types';
|
||||
import { showToast } from '@hooks/showToast';
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
@@ -105,3 +105,33 @@ export const updateCategoryOrderInDb = (categories: Category[]): void =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const getAllNovelCategories = (): Promise<NovelCategory[]> => {
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT * FROM NovelCategory',
|
||||
[],
|
||||
(txObj, { rows }) => resolve((rows as any)._array),
|
||||
txnErrorCallback,
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const _restoreCategory = (category: BackupCategory) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql('DELETE FROM Category WHERE id = ?', [category.id]);
|
||||
tx.executeSql('INSERT INTO Category (id, name, sort) VALUES (?, ?, ?)', [
|
||||
category.id,
|
||||
category.name,
|
||||
category.sort,
|
||||
]);
|
||||
for (const novelId of category.novelIds) {
|
||||
tx.executeSql(
|
||||
'INSERT INTO NovelCategory (categoryId, novelId) VALUES (?, ?)',
|
||||
[category.id, novelId],
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -47,12 +47,12 @@ export const insertChapters = async (
|
||||
};
|
||||
|
||||
const getChaptersQuery = (sort = 'ORDER BY id ASC', filter = '') =>
|
||||
`SELECT id, * FROM Chapter WHERE novelId = ? ${filter} ${sort}`;
|
||||
`SELECT * FROM Chapter WHERE novelId = ? ${filter} ${sort}`;
|
||||
|
||||
export const getChapters = (
|
||||
novelId: number,
|
||||
sort: string,
|
||||
filter: string,
|
||||
sort?: string,
|
||||
filter?: string,
|
||||
): Promise<ChapterInfo[]> => {
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { showToast } from '@hooks/showToast';
|
||||
import { txnErrorCallback } from '../utils/helpers';
|
||||
import { noop } from 'lodash-es';
|
||||
import { getString } from '@strings/translations';
|
||||
import { NovelInfo } from '../types';
|
||||
import { BackupNovel, NovelInfo } from '../types';
|
||||
import { SourceNovel } from '@plugins/types';
|
||||
import { NovelDownloadFolder } from '@utils/constants/download';
|
||||
|
||||
@@ -29,12 +29,12 @@ export const insertNovelAndChapters = (
|
||||
sourceNovel.url,
|
||||
pluginId,
|
||||
sourceNovel.name,
|
||||
sourceNovel.cover || '',
|
||||
sourceNovel.summary || '',
|
||||
sourceNovel.author || '',
|
||||
sourceNovel.artist || '',
|
||||
sourceNovel.status || '',
|
||||
sourceNovel.genres || '',
|
||||
sourceNovel.cover || null,
|
||||
sourceNovel.summary || null,
|
||||
sourceNovel.author || null,
|
||||
sourceNovel.artist || null,
|
||||
sourceNovel.status || null,
|
||||
sourceNovel.genres || null,
|
||||
],
|
||||
(txObj, resultSet) => {
|
||||
if (resultSet.insertId) {
|
||||
@@ -49,6 +49,28 @@ export const insertNovelAndChapters = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllNovels = async (): Promise<NovelInfo[]> => {
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
tx.executeSql('SELECT * FROM Novel', [], (txObj, { rows }) =>
|
||||
resolve(rows._array),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const getNovelsWithCustomCover = async (): Promise<NovelInfo[]> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT id, pluginId, cover FROM Novel WHERE cover NOT LIKE "http%"',
|
||||
[],
|
||||
(txObj, { rows }) => resolve(rows._array),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getNovel = async (novelUrl: string): Promise<NovelInfo> => {
|
||||
return new Promise(resolve =>
|
||||
db.transaction(tx => {
|
||||
@@ -77,11 +99,11 @@ export const switchNovelToLibrary = async (
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'UPDATE Novel SET inLibrary = ? WHERE id = ?',
|
||||
[1 - novel.inLibrary, novel.id],
|
||||
[Number(!novel.inLibrary), novel.id],
|
||||
noop,
|
||||
txnErrorCallback,
|
||||
);
|
||||
if (novel.inLibrary === 1) {
|
||||
if (novel.inLibrary) {
|
||||
tx.executeSql(
|
||||
'DELETE FROM NovelCategory WHERE novelId = ?',
|
||||
[novel.id],
|
||||
@@ -179,7 +201,7 @@ export const restoreLibrary = async (novel: NovelInfo) => {
|
||||
novel.artist || '',
|
||||
novel.status || '',
|
||||
novel.genres || '',
|
||||
novel.inLibrary,
|
||||
Number(novel.inLibrary),
|
||||
],
|
||||
async (txObj, { insertId }) => {
|
||||
if (!insertId) {
|
||||
@@ -217,7 +239,7 @@ export const updateNovelInfo = async (info: NovelInfo) => {
|
||||
info.artist || '',
|
||||
info.genres || '',
|
||||
info.status || '',
|
||||
info.isLocal,
|
||||
Number(info.isLocal),
|
||||
info.id,
|
||||
],
|
||||
noop,
|
||||
@@ -305,3 +327,55 @@ export const updateNovelCategories = async (
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const _restoreNovelAndChapters = (novel: BackupNovel) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql('DELETE FROM Novel WHERE id = ?', [novel.id]);
|
||||
tx.executeSql(
|
||||
`INSERT INTO
|
||||
Novel (
|
||||
id, url, pluginId, name, cover, summary,
|
||||
author, artist, status, genres, inLibrary, isLocal
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
novel.id,
|
||||
novel.url,
|
||||
novel.pluginId,
|
||||
novel.name,
|
||||
novel.cover || null,
|
||||
novel.summary || null,
|
||||
novel.author || null,
|
||||
novel.artist || null,
|
||||
novel.status || null,
|
||||
novel.genres || null,
|
||||
Number(novel.inLibrary),
|
||||
Number(novel.isLocal),
|
||||
],
|
||||
);
|
||||
for (const chapter of novel.chapters) {
|
||||
tx.executeSql(
|
||||
`INSERT INTO
|
||||
Chapter (
|
||||
id, novelId, url, name, releaseTime, bookmark,
|
||||
unread, readTime, isDownloaded, updatedTime
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
chapter.id,
|
||||
chapter.novelId,
|
||||
chapter.url,
|
||||
chapter.name,
|
||||
chapter.releaseTime || null,
|
||||
Number(chapter.bookmark),
|
||||
Number(chapter.unread),
|
||||
chapter.readTime,
|
||||
Number(chapter.isDownloaded),
|
||||
chapter.updatedTime,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -26,10 +26,11 @@ export interface ChapterInfo {
|
||||
url: string;
|
||||
name: string;
|
||||
releaseTime?: string;
|
||||
readTime: string;
|
||||
readTime: string | null;
|
||||
bookmark: boolean;
|
||||
unread: boolean;
|
||||
isDownloaded: boolean;
|
||||
updatedTime: string | null;
|
||||
|
||||
// download screen need this :)
|
||||
pluginId: string;
|
||||
@@ -77,6 +78,11 @@ export interface Category {
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface NovelCategory {
|
||||
novelId: number;
|
||||
categoryId: number;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
novelsCount?: number;
|
||||
chaptersCount?: number;
|
||||
@@ -87,3 +93,11 @@ export interface LibraryStats {
|
||||
genres?: Record<string, number>;
|
||||
status?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BackupNovel extends NovelInfo {
|
||||
chapters: ChapterInfo[];
|
||||
}
|
||||
|
||||
export interface BackupCategory extends Category {
|
||||
novelIds: number[];
|
||||
}
|
||||
|
||||
@@ -7,76 +7,62 @@ import {
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import WebView from 'react-native-webview';
|
||||
import Tts from 'react-native-tts';
|
||||
import * as Speech from 'expo-speech';
|
||||
import { htmlToText } from '@plugins/helpers/htmlToText';
|
||||
|
||||
interface TextToSpeechData {
|
||||
currentIndex: number;
|
||||
sentences: string[];
|
||||
export const enum TextToSpeechStatus {
|
||||
PAUSED,
|
||||
PROGRESS,
|
||||
}
|
||||
|
||||
export const useTextToSpeech = (
|
||||
chapterText: string,
|
||||
webViewRef: RefObject<WebView>,
|
||||
markChapterAsRead: () => void,
|
||||
): [string, () => void] => {
|
||||
const [ttsStatus, setTtsStatus] = useState('');
|
||||
const data = useRef<TextToSpeechData>({
|
||||
currentIndex: 0,
|
||||
sentences: [],
|
||||
} as TextToSpeechData);
|
||||
data.current.sentences = useMemo(
|
||||
): [TextToSpeechStatus, () => void] => {
|
||||
const [ttsStatus, setTtsStatus] = useState(TextToSpeechStatus.PAUSED);
|
||||
const index = useRef<number>(0);
|
||||
const sentences: string[] = useMemo(
|
||||
() =>
|
||||
htmlToText(chapterText)
|
||||
.replace(/<t-t-s>/g, '')
|
||||
.split('</t-t-s>'),
|
||||
[chapterText],
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (data.current.currentIndex < data.current.sentences.length) {
|
||||
Tts.speak(data.current.sentences[data.current.currentIndex], {
|
||||
iosVoiceId: '',
|
||||
rate: 0.5,
|
||||
androidParams: {
|
||||
KEY_PARAM_STREAM: 'STREAM_MUSIC',
|
||||
KEY_PARAM_VOLUME: 1,
|
||||
KEY_PARAM_PAN: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
markChapterAsRead();
|
||||
setTtsStatus('finished');
|
||||
Tts.stop();
|
||||
}
|
||||
}, []);
|
||||
Speech.speak(sentences[index.current], {
|
||||
onStart() {
|
||||
webViewRef.current?.injectJavaScript(`tts.play(${index.current})`);
|
||||
},
|
||||
onDone() {
|
||||
if (index.current === sentences.length) {
|
||||
index.current = 0;
|
||||
setTtsStatus(TextToSpeechStatus.PAUSED);
|
||||
markChapterAsRead();
|
||||
} else {
|
||||
index.current = index.current + 1;
|
||||
play();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [sentences]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Tts.removeAllListeners('tts-finish');
|
||||
Tts.removeAllListeners('tts-start');
|
||||
Tts.stop();
|
||||
Speech.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startTts = useCallback(() => {
|
||||
if (ttsStatus === 'progress') {
|
||||
setTtsStatus('paused');
|
||||
data.current.currentIndex -= 1;
|
||||
Tts.stop();
|
||||
Tts.removeAllListeners('tts-finish');
|
||||
Tts.removeAllListeners('tts-start');
|
||||
return;
|
||||
const startTts = () => {
|
||||
if (ttsStatus === TextToSpeechStatus.PROGRESS) {
|
||||
setTtsStatus(TextToSpeechStatus.PAUSED);
|
||||
Speech.stop();
|
||||
} else {
|
||||
setTtsStatus(TextToSpeechStatus.PROGRESS);
|
||||
play();
|
||||
}
|
||||
Tts.addEventListener('tts-finish', () => play());
|
||||
Tts.addEventListener('tts-start', () => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`tts.play(${data.current.currentIndex})`,
|
||||
);
|
||||
data.current.currentIndex += 1;
|
||||
});
|
||||
setTtsStatus('progress');
|
||||
play();
|
||||
}, [ttsStatus]);
|
||||
};
|
||||
|
||||
return [ttsStatus, startTts];
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import color from 'color';
|
||||
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
|
||||
import { IconButton } from 'react-native-paper';
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
@@ -93,8 +93,9 @@ const NovelInfoHeader = ({
|
||||
})
|
||||
}
|
||||
onLongPress={() => {
|
||||
Clipboard.setString(novel.name);
|
||||
showToast('Copied to clipboard: ' + novel.name);
|
||||
Clipboard.setStringAsync(novel.name).then(() =>
|
||||
showToast('Copied to clipboard: ' + novel.name),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{novel.name}
|
||||
|
||||
@@ -197,7 +197,7 @@ export const ChapterContent = ({
|
||||
[webViewRef],
|
||||
);
|
||||
|
||||
let scrollInterval: NodeJS.Timer;
|
||||
let scrollInterval: NodeJS.Timeout;
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
scrollInterval = setInterval(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { bookmarkChapterAction } from '../../../redux/novel/novel.actions';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
import { useAppDispatch } from '@redux/hooks';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { TextToSpeechStatus } from '@hooks/useTextToSpeech';
|
||||
|
||||
interface ReaderAppbarProps {
|
||||
bookmark: boolean;
|
||||
@@ -16,7 +17,7 @@ interface ReaderAppbarProps {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
tts: () => void;
|
||||
textToSpeech: string;
|
||||
textToSpeech: TextToSpeechStatus;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -68,11 +69,17 @@ const ReaderAppbar = ({
|
||||
</Text>
|
||||
</View>
|
||||
<Appbar.Action
|
||||
icon={textToSpeech === 'progress' ? 'pause' : 'volume-high'}
|
||||
icon={
|
||||
textToSpeech === TextToSpeechStatus.PROGRESS
|
||||
? 'pause'
|
||||
: 'volume-high'
|
||||
}
|
||||
size={24}
|
||||
onPress={tts}
|
||||
iconColor={
|
||||
textToSpeech === 'progress' ? theme.primary : theme.onSurface
|
||||
textToSpeech === TextToSpeechStatus.PROGRESS
|
||||
? theme.primary
|
||||
: theme.onSurface
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ScreenContainer } from '../../components/Common';
|
||||
|
||||
import {
|
||||
createBackup,
|
||||
restoreBackup,
|
||||
restoreError,
|
||||
} from '../../services/backup/v1/backup';
|
||||
|
||||
import { useTheme } from '@hooks/useTheme';
|
||||
import { Appbar, List } from '@components';
|
||||
import { Portal } from 'react-native-paper';
|
||||
import useBoolean from '@hooks/useBoolean';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import { remoteBackup, remoteRestore } from '@services/backup/remote';
|
||||
import { BackupSettingsScreenProps } from '@navigators/types';
|
||||
|
||||
const BackupSettings = ({ navigation }: BackupSettingsScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const [ipv4, setIpv4] = useState('');
|
||||
const [port, setPort] = useState('8000');
|
||||
const {
|
||||
value: backupModalVisible,
|
||||
setTrue: showBackupModal,
|
||||
setFalse: closeBackupModal,
|
||||
} = useBoolean();
|
||||
|
||||
const {
|
||||
value: restoreModalVisible,
|
||||
setTrue: showRestoreModal,
|
||||
setFalse: closeRestoreModal,
|
||||
} = useBoolean();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenContainer theme={theme}>
|
||||
<Appbar
|
||||
title="Backup"
|
||||
handleGoBack={() => navigation.goBack()}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Section>
|
||||
<List.SubHeader theme={theme}>Backup</List.SubHeader>
|
||||
<List.Item
|
||||
title="Remote backup"
|
||||
description="Backup data to your pc"
|
||||
onPress={showBackupModal}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title="Remote restore"
|
||||
description="Restore data from your pc"
|
||||
onPress={showRestoreModal}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title="Create backup"
|
||||
description="Can be used to restore current library"
|
||||
onPress={createBackup}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title="Restore backup"
|
||||
description="Restore library from backup file"
|
||||
onPress={() => restoreBackup()}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title="Restore error backup"
|
||||
description="If there were errors when restoring backup. Using this to restore the remaining. Save your time."
|
||||
onPress={restoreError}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.InfoItem
|
||||
title="Restoring large backups may freeze the app until restoring is finished"
|
||||
icon="information-outline"
|
||||
theme={theme}
|
||||
/>
|
||||
<List.InfoItem
|
||||
title="Create backup may not work on devices with Android 9 or lower."
|
||||
icon="information-outline"
|
||||
theme={theme}
|
||||
/>
|
||||
</List.Section>
|
||||
</ScreenContainer>
|
||||
<Portal>
|
||||
<ConnectionModal
|
||||
title="Remote Backup"
|
||||
ipv4={ipv4}
|
||||
port={port}
|
||||
visible={backupModalVisible}
|
||||
theme={theme}
|
||||
closeModal={closeBackupModal}
|
||||
handle={remoteBackup}
|
||||
setIpv4={setIpv4}
|
||||
setPort={setPort}
|
||||
/>
|
||||
<ConnectionModal
|
||||
title="Remote Restore"
|
||||
ipv4={ipv4}
|
||||
port={port}
|
||||
visible={restoreModalVisible}
|
||||
theme={theme}
|
||||
closeModal={closeRestoreModal}
|
||||
handle={remoteRestore}
|
||||
setIpv4={setIpv4}
|
||||
setPort={setPort}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupSettings;
|
||||
@@ -0,0 +1,355 @@
|
||||
import React from 'react';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Modal, TextInput, overlay } from 'react-native-paper';
|
||||
import { GoogleSignin, User } from '@react-native-google-signin/google-signin';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, EmptyView } from '@components';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { FlatList, TouchableOpacity } from 'react-native-gesture-handler';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { showToast } from '@hooks/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import { exists, getBackups, makeDir } from '@api/drive';
|
||||
import { createBackup, driveRestore } from '@services/backup/drive';
|
||||
import { DriveFile } from '@api/drive/types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
enum BackupModal {
|
||||
UNAUTHORIZED,
|
||||
AUTHORIZED,
|
||||
CREATE_BACKUP,
|
||||
RESTORE_BACKUP,
|
||||
}
|
||||
|
||||
function Authorized({
|
||||
theme,
|
||||
setBackupModal,
|
||||
setUser,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
setUser: (user?: User) => void;
|
||||
}) {
|
||||
const signOut = () => {
|
||||
GoogleSignin.signOut().then(() => {
|
||||
setUser();
|
||||
setBackupModal(BackupModal.UNAUTHORIZED);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
title="Backup"
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={() => setBackupModal(BackupModal.CREATE_BACKUP)}
|
||||
/>
|
||||
<Button
|
||||
title="Restore"
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={() => setBackupModal(BackupModal.RESTORE_BACKUP)}
|
||||
/>
|
||||
<Button
|
||||
title="Sign out"
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={signOut}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UnAuthorized({
|
||||
theme,
|
||||
setBackupModal,
|
||||
setUser,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
setUser: (user?: User) => void;
|
||||
}) {
|
||||
const signIn = () => {
|
||||
GoogleSignin.hasPlayServices()
|
||||
.then(hasPlayServices => {
|
||||
if (hasPlayServices) {
|
||||
return GoogleSignin.signIn();
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
setUser(user);
|
||||
setBackupModal(BackupModal.AUTHORIZED);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
title="Sign in"
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={signIn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateBackup({
|
||||
theme,
|
||||
setBackupModal,
|
||||
closeModal,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
closeModal: () => void;
|
||||
}) {
|
||||
const [backupName, setBackupName] = useState('');
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
const prepare = async () => {
|
||||
setFetching(true);
|
||||
let rootFolder = await exists('LNReader', true, undefined, true);
|
||||
if (!rootFolder) {
|
||||
rootFolder = await makeDir('LNReader');
|
||||
}
|
||||
const backupFolderName = backupName.trim() + '.backup';
|
||||
let backupFolder = await exists(backupFolderName, true, rootFolder.id);
|
||||
if (!backupFolder) {
|
||||
backupFolder = await makeDir(backupFolderName, rootFolder.id);
|
||||
}
|
||||
setFetching(false);
|
||||
return backupFolder;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
value={backupName}
|
||||
placeholder={'Backup name'}
|
||||
onChangeText={setBackupName}
|
||||
mode="outlined"
|
||||
underlineColor={theme.outline}
|
||||
theme={{ colors: { ...theme } }}
|
||||
placeholderTextColor={theme.onSurfaceDisabled}
|
||||
disabled={fetching}
|
||||
/>
|
||||
<View style={styles.footerContainer}>
|
||||
<Button
|
||||
disabled={backupName.trim().length === 0 || fetching}
|
||||
title={getString('common.ok')}
|
||||
onPress={() => {
|
||||
prepare().then(folder => {
|
||||
closeModal();
|
||||
return createBackup(folder);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.cancel')}
|
||||
onPress={() => setBackupModal(BackupModal.AUTHORIZED)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RestoreBackup({
|
||||
theme,
|
||||
setBackupModal,
|
||||
closeModal,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
closeModal: () => void;
|
||||
}) {
|
||||
const [backupList, setBackupList] = useState<DriveFile[]>([]);
|
||||
useEffect(() => {
|
||||
exists('LNReader', true, undefined, true).then(rootFolder => {
|
||||
if (rootFolder) {
|
||||
getBackups(rootFolder.id, true).then(backups => setBackupList(backups));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.backupList}
|
||||
data={backupList}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<Button
|
||||
mode="outlined"
|
||||
style={styles.btnOutline}
|
||||
onPress={() => {
|
||||
closeModal();
|
||||
driveRestore(item);
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.primary }}>
|
||||
{item.name.replace(/\.backup$/, ' ')}
|
||||
</Text>
|
||||
<Text style={{ color: theme.secondary, fontSize: 12 }}>
|
||||
{'(' + dayjs(item.createdTime).format('LL') + ')'}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<EmptyView description="No backup founded" theme={theme} />
|
||||
)}
|
||||
/>
|
||||
<View style={styles.footerContainer}>
|
||||
<Button
|
||||
title={getString('common.cancel')}
|
||||
onPress={() => setBackupModal(BackupModal.AUTHORIZED)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface GoogleDriveModalProps {
|
||||
visible: boolean;
|
||||
theme: ThemeColors;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export default function GoogleDriveModal({
|
||||
visible,
|
||||
theme,
|
||||
closeModal,
|
||||
}: GoogleDriveModalProps) {
|
||||
const [backupModal, setBackupModal] = useState(BackupModal.UNAUTHORIZED);
|
||||
const [user, setUser] = useState<User | null | undefined>(null);
|
||||
useEffect(() => {
|
||||
GoogleSignin.configure({
|
||||
scopes: ['https://www.googleapis.com/auth/drive'],
|
||||
});
|
||||
GoogleSignin.isSignedIn()
|
||||
.then(isSignedIn => {
|
||||
if (isSignedIn) {
|
||||
return GoogleSignin.getCurrentUser();
|
||||
} else {
|
||||
setBackupModal(BackupModal.UNAUTHORIZED);
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
if (user) {
|
||||
setUser(user);
|
||||
setBackupModal(BackupModal.AUTHORIZED);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderModal = () => {
|
||||
switch (backupModal) {
|
||||
case BackupModal.AUTHORIZED:
|
||||
return (
|
||||
<Authorized
|
||||
theme={theme}
|
||||
setBackupModal={setBackupModal}
|
||||
setUser={setUser}
|
||||
/>
|
||||
);
|
||||
case BackupModal.UNAUTHORIZED:
|
||||
return (
|
||||
<UnAuthorized
|
||||
theme={theme}
|
||||
setBackupModal={setBackupModal}
|
||||
setUser={setUser}
|
||||
/>
|
||||
);
|
||||
case BackupModal.CREATE_BACKUP:
|
||||
return (
|
||||
<CreateBackup
|
||||
theme={theme}
|
||||
setBackupModal={setBackupModal}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
case BackupModal.RESTORE_BACKUP:
|
||||
return (
|
||||
<RestoreBackup
|
||||
theme={theme}
|
||||
setBackupModal={setBackupModal}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismiss={closeModal}
|
||||
contentContainerStyle={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: overlay(2, theme.surface) },
|
||||
]}
|
||||
>
|
||||
<>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
|
||||
Google Drive Backup
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onLongPress={() => {
|
||||
if (user?.user.email) {
|
||||
Clipboard.setStringAsync(user.user.email).then(success => {
|
||||
if (success) {
|
||||
showToast('Copied to clipboard: ' + user.user.email);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FastImage
|
||||
source={{ uri: user?.user.photo || '' }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{renderModal()}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
margin: 30,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 32,
|
||||
borderRadius: 32,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
textAlignVertical: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
borderRadius: 40,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
},
|
||||
footerContainer: {
|
||||
marginTop: 24,
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
btnOutline: {
|
||||
marginVertical: 4,
|
||||
borderWidth: 1,
|
||||
},
|
||||
loadingContent: {
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
},
|
||||
error: {
|
||||
fontSize: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
backupList: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { list } from '@api/remote';
|
||||
import { Button, EmptyView } from '@components';
|
||||
import { createBackup, remoteRestore } from '@services/backup/remote';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { fetchTimeout } from '@utils/fetch/fetch';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { useMMKVString } from 'react-native-mmkv';
|
||||
import { Modal, TextInput, overlay } from 'react-native-paper';
|
||||
|
||||
enum BackupModal {
|
||||
SET_HOST,
|
||||
CONNECTED,
|
||||
CREATE_BACKUP,
|
||||
RESTORE_BACKUP,
|
||||
}
|
||||
|
||||
interface SelfHostModalProps {
|
||||
visible: boolean;
|
||||
theme: ThemeColors;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
function CreateBackup({
|
||||
host,
|
||||
theme,
|
||||
setBackupModal,
|
||||
closeModal,
|
||||
}: {
|
||||
host: string;
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
closeModal: () => void;
|
||||
}) {
|
||||
const [backupName, setBackupName] = useState('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
value={backupName}
|
||||
placeholder={'Backup name'}
|
||||
onChangeText={setBackupName}
|
||||
mode="outlined"
|
||||
underlineColor={theme.outline}
|
||||
theme={{ colors: { ...theme } }}
|
||||
placeholderTextColor={theme.onSurfaceDisabled}
|
||||
/>
|
||||
<View style={styles.footerContainer}>
|
||||
<Button
|
||||
disabled={backupName.trim().length === 0}
|
||||
title={getString('common.ok')}
|
||||
onPress={() => {
|
||||
closeModal();
|
||||
createBackup(host, backupName + '.backup');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.cancel')}
|
||||
onPress={() => setBackupModal(BackupModal.CONNECTED)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RestoreBackup({
|
||||
host,
|
||||
theme,
|
||||
setBackupModal,
|
||||
closeModal,
|
||||
}: {
|
||||
host: string;
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
closeModal: () => void;
|
||||
}) {
|
||||
const [backupList, setBackupList] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
list(host, []).then(items =>
|
||||
setBackupList(items.filter(item => item.endsWith('.backup'))),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.backupList}
|
||||
data={backupList}
|
||||
keyExtractor={(item, index) => item + '_' + index}
|
||||
renderItem={({ item }) => (
|
||||
<Button
|
||||
mode="outlined"
|
||||
style={styles.btnOutline}
|
||||
onPress={() => {
|
||||
closeModal();
|
||||
remoteRestore(host, item);
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.primary }}>
|
||||
{item.replace(/\.backup$/, ' ')}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<EmptyView description="No backup founded" theme={theme} />
|
||||
)}
|
||||
/>
|
||||
<View style={styles.footerContainer}>
|
||||
<Button
|
||||
title={getString('common.cancel')}
|
||||
onPress={() => setBackupModal(BackupModal.CONNECTED)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SetHost({
|
||||
host,
|
||||
setHost,
|
||||
theme,
|
||||
setBackupModal,
|
||||
}: {
|
||||
host: string;
|
||||
setHost: (
|
||||
value:
|
||||
| string
|
||||
| ((current: string | undefined) => string | undefined)
|
||||
| undefined,
|
||||
) => void;
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
}) {
|
||||
const [error, setError] = useState('');
|
||||
const [fetching, setFetching] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
value={host}
|
||||
placeholder={'Host'}
|
||||
onChangeText={setHost}
|
||||
mode="outlined"
|
||||
underlineColor={theme.outline}
|
||||
theme={{ colors: { ...theme } }}
|
||||
placeholderTextColor={theme.onSurfaceDisabled}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{error ? (
|
||||
<Text style={[styles.error, { color: theme.error }]}>{error}</Text>
|
||||
) : null}
|
||||
<View style={styles.footerContainer}>
|
||||
<Button
|
||||
disabled={host.trim().length === 0 || fetching}
|
||||
title={getString('common.ok')}
|
||||
onPress={() => {
|
||||
setError('');
|
||||
setFetching(true);
|
||||
fetchTimeout(host, {}, 2000)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.name === 'LNReader') {
|
||||
setBackupModal(BackupModal.CONNECTED);
|
||||
} else {
|
||||
throw new Error('Unknown host');
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
setError(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setFetching(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Connected({
|
||||
theme,
|
||||
setBackupModal,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
setBackupModal: (backupModal: BackupModal) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
title="Backup"
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={() => setBackupModal(BackupModal.CREATE_BACKUP)}
|
||||
/>
|
||||
<Button
|
||||
title="Restore"
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={() => setBackupModal(BackupModal.RESTORE_BACKUP)}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.cancel')}
|
||||
style={[styles.btnOutline, { borderColor: theme.outline }]}
|
||||
onPress={() => setBackupModal(BackupModal.SET_HOST)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SelfHostModal({
|
||||
visible,
|
||||
theme,
|
||||
closeModal,
|
||||
}: SelfHostModalProps) {
|
||||
const [backupModal, setBackupModal] = useState(BackupModal.SET_HOST);
|
||||
const [host = '', setHost] = useMMKVString('remote-service');
|
||||
|
||||
const renderModal = () => {
|
||||
switch (backupModal) {
|
||||
case BackupModal.SET_HOST:
|
||||
return (
|
||||
<SetHost
|
||||
host={host}
|
||||
setHost={setHost}
|
||||
theme={theme}
|
||||
setBackupModal={setBackupModal}
|
||||
/>
|
||||
);
|
||||
case BackupModal.CONNECTED:
|
||||
return <Connected theme={theme} setBackupModal={setBackupModal} />;
|
||||
case BackupModal.CREATE_BACKUP:
|
||||
return (
|
||||
<CreateBackup
|
||||
host={host}
|
||||
closeModal={closeModal}
|
||||
setBackupModal={setBackupModal}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
case BackupModal.RESTORE_BACKUP:
|
||||
return (
|
||||
<RestoreBackup
|
||||
host={host}
|
||||
closeModal={closeModal}
|
||||
setBackupModal={setBackupModal}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismiss={closeModal}
|
||||
contentContainerStyle={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: overlay(2, theme.surface) },
|
||||
]}
|
||||
>
|
||||
<>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
|
||||
Self Host Backup
|
||||
</Text>
|
||||
</View>
|
||||
{renderModal()}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
margin: 30,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 32,
|
||||
borderRadius: 32,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
textAlignVertical: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
borderRadius: 40,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
},
|
||||
footerContainer: {
|
||||
marginTop: 24,
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
btnOutline: {
|
||||
marginVertical: 4,
|
||||
borderWidth: 1,
|
||||
},
|
||||
loadingContent: {
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
},
|
||||
error: {
|
||||
fontSize: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
backupList: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ScreenContainer } from '@components/Common';
|
||||
|
||||
import { useTheme } from '@hooks/useTheme';
|
||||
import { Appbar, List } from '@components';
|
||||
import { Portal } from 'react-native-paper';
|
||||
import useBoolean from '@hooks/useBoolean';
|
||||
// import { remoteBackup, remoteRestore } from '@services/backup/remote';
|
||||
import { BackupSettingsScreenProps } from '@navigators/types';
|
||||
import GoogleDriveModal from './Components/GoogleDriveModal';
|
||||
import { useMMKVBoolean } from 'react-native-mmkv';
|
||||
import SelfHostModal from './Components/SelfHostModal';
|
||||
import {
|
||||
createBackup as deprecatedCreateBackup,
|
||||
restoreBackup as deprecatedRestoreBackup,
|
||||
restoreError as deprecatedRestoreError,
|
||||
} from '@services/backup/legacy';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
|
||||
const BackupSettings = ({ navigation }: BackupSettingsScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
value: googleDriveModalVisible,
|
||||
setFalse: closeGoogleDriveModal,
|
||||
setTrue: openGoogleDriveModal,
|
||||
} = useBoolean();
|
||||
|
||||
const {
|
||||
value: selfHostModalVisible,
|
||||
setFalse: closeSelfHostModal,
|
||||
setTrue: openSelfHostModal,
|
||||
} = useBoolean();
|
||||
|
||||
const [hasBackgroundTask] = useMMKVBoolean('HAS_BACKGROUND_TASK');
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenContainer theme={theme}>
|
||||
<Appbar
|
||||
title="Backup"
|
||||
handleGoBack={() => navigation.goBack()}
|
||||
theme={theme}
|
||||
/>
|
||||
<ScrollView style={{ paddingBottom: 40 }}>
|
||||
<List.Section>
|
||||
<List.SubHeader theme={theme}>Remote Backup</List.SubHeader>
|
||||
<List.Item
|
||||
title="Self Host"
|
||||
description="Backup to your server"
|
||||
theme={theme}
|
||||
onPress={openSelfHostModal}
|
||||
disabled={hasBackgroundTask}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Googe Drive"
|
||||
description="Backup to your Google Drive"
|
||||
theme={theme}
|
||||
onPress={openGoogleDriveModal}
|
||||
disabled={hasBackgroundTask}
|
||||
/>
|
||||
<List.SubHeader theme={theme}>Legacy Backup</List.SubHeader>
|
||||
<List.Item
|
||||
title="Create backup (Deprecated)"
|
||||
description="Can be used to restore current library"
|
||||
onPress={deprecatedCreateBackup}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title="Restore backup (Deprecated)"
|
||||
description="Restore library from backup file"
|
||||
onPress={() => deprecatedRestoreBackup()}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title="Restore error (Deprecated)"
|
||||
description="Using this to restore the remaining. Save your time."
|
||||
onPress={deprecatedRestoreError}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.InfoItem
|
||||
title="Restoring large backups may freeze the app until restoring is finished"
|
||||
icon="information-outline"
|
||||
theme={theme}
|
||||
/>
|
||||
<List.InfoItem
|
||||
title="Create backup may not work on devices with Android 9 or lower."
|
||||
icon="information-outline"
|
||||
theme={theme}
|
||||
/>
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
<Portal>
|
||||
<GoogleDriveModal
|
||||
visible={googleDriveModalVisible}
|
||||
theme={theme}
|
||||
closeModal={closeGoogleDriveModal}
|
||||
/>
|
||||
<SelfHostModal
|
||||
theme={theme}
|
||||
visible={selfHostModalVisible}
|
||||
closeModal={closeSelfHostModal}
|
||||
/>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupSettings;
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
BackupPackage,
|
||||
BackupTask,
|
||||
BackupDataFileName,
|
||||
TaskType,
|
||||
} from '../types';
|
||||
import { appVersion } from '@utils/versionUtils';
|
||||
import {
|
||||
getAllNovels,
|
||||
getNovelsWithCustomCover,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import {
|
||||
AppDownloadFolder,
|
||||
NovelDownloadFolder,
|
||||
} from '@utils/constants/download';
|
||||
import { getChapters } from '@database/queries/ChapterQueries';
|
||||
import {
|
||||
getCategoriesFromDb,
|
||||
getAllNovelCategories,
|
||||
} from '@database/queries/CategoryQueries';
|
||||
import { walkDir } from '../utils';
|
||||
import { store } from '@redux/store';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
|
||||
export const versionTask = async (parentId: string): Promise<BackupTask> => {
|
||||
const backupPackage: BackupPackage = {
|
||||
folderTree: [parentId],
|
||||
content: JSON.stringify({
|
||||
version: appVersion,
|
||||
}),
|
||||
name: BackupDataFileName.VERSION,
|
||||
mimeType: 'application/json',
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.VERSION,
|
||||
subtasks: [async () => backupPackage],
|
||||
};
|
||||
};
|
||||
|
||||
export const novelTask = async (parentId: string): Promise<BackupTask> => {
|
||||
return getAllNovels().then(novels => {
|
||||
const subtasks = novels.map(novel => {
|
||||
if (novel.cover && !novel.cover.startsWith('http')) {
|
||||
novel.cover = `file:///${NovelDownloadFolder}/${novel.pluginId}/${novel.id}/cover.png`;
|
||||
}
|
||||
return (): Promise<BackupPackage> =>
|
||||
getChapters(novel.id).then(chapters => {
|
||||
return {
|
||||
folderTree: [parentId],
|
||||
name: novel.id + '.json',
|
||||
content: JSON.stringify({
|
||||
chapters: chapters,
|
||||
...novel,
|
||||
}),
|
||||
mimeType: 'application/json',
|
||||
};
|
||||
});
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.NOVEL_AND_CHAPTERS,
|
||||
subtasks: subtasks,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const novelCoverTask = (parentId: string): Promise<BackupTask> => {
|
||||
return getNovelsWithCustomCover().then(novels => {
|
||||
return {
|
||||
taskType: TaskType.NOVEL_COVER,
|
||||
subtasks: novels.map(novel => {
|
||||
return async (): Promise<BackupPackage> => {
|
||||
return {
|
||||
folderTree: [parentId],
|
||||
name: `${NovelDownloadFolder}/${novel.pluginId}/${novel.id}/cover.png`.replace(
|
||||
AppDownloadFolder + '/',
|
||||
'',
|
||||
),
|
||||
mimeType: 'image/png',
|
||||
content: novel.cover || '',
|
||||
};
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const categoryTask = (parentId: string): Promise<BackupTask> => {
|
||||
return getCategoriesFromDb().then(categories => {
|
||||
const task = async (): Promise<BackupPackage> => {
|
||||
return getAllNovelCategories().then(novelCategories => {
|
||||
return {
|
||||
folderTree: [parentId],
|
||||
name: BackupDataFileName.CATEGORY,
|
||||
mimeType: 'application/json',
|
||||
content: JSON.stringify(
|
||||
categories.map(category => {
|
||||
return {
|
||||
...category,
|
||||
novelIds: novelCategories
|
||||
.filter(nc => nc.categoryId === category.id)
|
||||
.map(nc => nc.novelId),
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.CATEGORY,
|
||||
subtasks: [task],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const downloadTask = (parentId: string): Promise<BackupTask> => {
|
||||
return walkDir(AppDownloadFolder).then(items => {
|
||||
return {
|
||||
taskType: TaskType.DOWNLOAD,
|
||||
subtasks: items.map(item => {
|
||||
return async (): Promise<BackupPackage> => {
|
||||
return {
|
||||
folderTree: [parentId],
|
||||
name: item.path.replace(AppDownloadFolder + '/', ''),
|
||||
content: item.uri,
|
||||
mimeType: item.mimeType,
|
||||
};
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const settingTask = async (parentId: string): Promise<BackupTask> => {
|
||||
const state = store.getState();
|
||||
state.trackerReducer = {
|
||||
'tracker': null,
|
||||
'trackedNovels': [],
|
||||
};
|
||||
const backupPackage: BackupPackage = {
|
||||
folderTree: [parentId],
|
||||
name: BackupDataFileName.SETTING,
|
||||
mimeType: 'application/json',
|
||||
content: JSON.stringify(state),
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.SETTING,
|
||||
subtasks: [async () => backupPackage],
|
||||
};
|
||||
};
|
||||
|
||||
export const themeTask = async (parentId: string): Promise<BackupTask> => {
|
||||
const APP_THEME = MMKVStorage.getString('APP_THEME');
|
||||
const AMOLED_BLACK = MMKVStorage.getBoolean('AMOLED_BLACK');
|
||||
const CUSTOM_ACCENT_COLOR = MMKVStorage.getString('CUSTOM_ACCENT_COLOR');
|
||||
|
||||
const backupPackage: BackupPackage = {
|
||||
folderTree: [parentId],
|
||||
name: BackupDataFileName.THEME,
|
||||
mimeType: 'application/json',
|
||||
content: JSON.stringify({
|
||||
APP_THEME,
|
||||
AMOLED_BLACK,
|
||||
CUSTOM_ACCENT_COLOR,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.THEME,
|
||||
subtasks: [async () => backupPackage],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { DriveFile } from '@api/drive/types';
|
||||
import { sleep } from '@utils/sleep';
|
||||
import BackgroundService from 'react-native-background-actions';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { createFile, exists, makeDir } from '@api/drive';
|
||||
import {
|
||||
categoryTask,
|
||||
downloadTask,
|
||||
novelCoverTask,
|
||||
novelTask,
|
||||
settingTask,
|
||||
themeTask,
|
||||
versionTask,
|
||||
} from './backupTasks';
|
||||
import {
|
||||
restoreCategory,
|
||||
restoreNovel,
|
||||
restoreSetting,
|
||||
restoreTheme,
|
||||
retoreDownload,
|
||||
} from './restoreTasks';
|
||||
|
||||
interface TaskData {
|
||||
delay: number;
|
||||
backupFolder: DriveFile;
|
||||
}
|
||||
|
||||
const driveBackupAction = async (taskData?: TaskData) => {
|
||||
try {
|
||||
if (!taskData) {
|
||||
throw new Error('No data provided');
|
||||
}
|
||||
const { delay, backupFolder } = taskData;
|
||||
await sleep(delay);
|
||||
|
||||
const dataFolder = await makeDir('Data', backupFolder.id);
|
||||
const downloadFolder = await makeDir('Download', backupFolder.id);
|
||||
const novelFolder = await makeDir('NovelAndChapters', dataFolder.id);
|
||||
|
||||
const taskList = [
|
||||
versionTask(dataFolder.id),
|
||||
novelTask(novelFolder.id),
|
||||
novelCoverTask(downloadFolder.id),
|
||||
categoryTask(dataFolder.id),
|
||||
downloadTask(downloadFolder.id),
|
||||
settingTask(dataFolder.id),
|
||||
themeTask(dataFolder.id),
|
||||
];
|
||||
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
const { taskType, subtasks } = await taskList[i];
|
||||
for (let j = 0; j < subtasks.length; j++) {
|
||||
await BackgroundService.updateNotification({
|
||||
taskDesc: `Backup ${taskType} (${j}/${subtasks.length})`,
|
||||
progressBar: {
|
||||
max: subtasks.length,
|
||||
value: j,
|
||||
},
|
||||
})
|
||||
.then(() => subtasks[j]())
|
||||
.then(backupPackage =>
|
||||
createFile(
|
||||
backupPackage.name,
|
||||
backupPackage.mimeType,
|
||||
backupPackage.content,
|
||||
backupPackage.folderTree[0],
|
||||
),
|
||||
)
|
||||
.then(() => sleep(delay))
|
||||
.catch(error => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Drive Backup',
|
||||
body: 'Done',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (BackgroundService.isRunning()) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Drive Backup Interruped',
|
||||
body: error.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
}
|
||||
} finally {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
}
|
||||
};
|
||||
|
||||
export const createBackup = async (backupFolder: DriveFile) => {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', true);
|
||||
try {
|
||||
return BackgroundService.start(driveBackupAction, {
|
||||
taskName: 'Drive Backup',
|
||||
taskTitle: 'Drive Backup',
|
||||
taskDesc: 'Preparing',
|
||||
taskIcon: { name: 'notification_icon', type: 'drawable' },
|
||||
color: '#00adb5',
|
||||
parameters: { delay: 500, backupFolder },
|
||||
linkingURI: 'lnreader://updates',
|
||||
});
|
||||
} catch (e: any) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Drive Backup Interruped',
|
||||
body: e.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const driveRestoreAction = async (taskData?: TaskData) => {
|
||||
try {
|
||||
if (!taskData) {
|
||||
throw new Error('No data provided');
|
||||
}
|
||||
const { delay, backupFolder } = taskData;
|
||||
await sleep(delay);
|
||||
|
||||
const dataFolder = await exists('Data', true, backupFolder.id);
|
||||
const downloadFolder = await exists('Download', true, backupFolder.id);
|
||||
const novelFolder = await exists('NovelAndChapters', true, dataFolder?.id);
|
||||
if (!dataFolder || !downloadFolder || !novelFolder) {
|
||||
throw new Error('Invalid backup folder');
|
||||
}
|
||||
|
||||
const taskList = [
|
||||
restoreNovel(novelFolder.id),
|
||||
restoreCategory(dataFolder.id),
|
||||
retoreDownload(downloadFolder.id),
|
||||
restoreSetting(dataFolder.id),
|
||||
restoreTheme(dataFolder.id),
|
||||
];
|
||||
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
const { taskType, subtasks } = await taskList[i]();
|
||||
for (let j = 0; j < subtasks.length; j++) {
|
||||
await BackgroundService.updateNotification({
|
||||
taskDesc: `Restore ${taskType} (${j}/${subtasks.length})`,
|
||||
progressBar: {
|
||||
max: subtasks.length,
|
||||
value: j,
|
||||
},
|
||||
})
|
||||
.then(() => subtasks[j]())
|
||||
.then(() => sleep(delay))
|
||||
.catch(error => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Drive Restore',
|
||||
body: 'Done',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
if (BackgroundService.isRunning()) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Drive Restore Interruped',
|
||||
body: error.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
}
|
||||
} finally {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
}
|
||||
};
|
||||
|
||||
export const driveRestore = async (backupFolder: DriveFile) => {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', true);
|
||||
try {
|
||||
return BackgroundService.start(driveRestoreAction, {
|
||||
taskName: 'Drive Restore',
|
||||
taskTitle: 'Drive Restore',
|
||||
taskDesc: 'Preparing',
|
||||
taskIcon: { name: 'notification_icon', type: 'drawable' },
|
||||
color: '#00adb5',
|
||||
parameters: { delay: 500, backupFolder },
|
||||
linkingURI: 'lnreader://updates',
|
||||
});
|
||||
} catch (e: any) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Drive Restore Interruped',
|
||||
body: e.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { exists, readDir, readFile } from '@api/drive';
|
||||
import { BackupDataFileName, RestoreTask, TaskType } from '../types';
|
||||
import { BackupCategory, BackupNovel } from '@database/types';
|
||||
import { _restoreNovelAndChapters } from '@database/queries/NovelQueries';
|
||||
import { _restoreCategory } from '@database/queries/CategoryQueries';
|
||||
import { store } from '@redux/store';
|
||||
import { RESTORE_NOVEL_STATE } from '@redux/novel/novel.types';
|
||||
import { restorePluginState } from '@redux/plugins/pluginsSlice';
|
||||
import { restorePreferenceState } from '@redux/preferences/preferencesSlice';
|
||||
import { restoreSettingsState } from '@redux/settings/settingsSliceV1';
|
||||
import { restoreSettingsState as restoreSettingsStateV2 } from '@redux/settings/settingsSliceV2';
|
||||
import { RESTORE_TRACKER_STATE } from '@redux/tracker/tracker.types';
|
||||
import { RESTORE_UPDATE_STATE } from '@redux/updates/updates.types';
|
||||
import { RESTORE_DOWNLOADS_STATE } from '@redux/downloads/donwloads.types';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
|
||||
export const restoreNovel = (
|
||||
parentId: string,
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
readDir(parentId).then(files => {
|
||||
const subtasks = files.map(file => {
|
||||
return (): Promise<void> => {
|
||||
return readFile(file, 'json').then((novel: BackupNovel) =>
|
||||
_restoreNovelAndChapters(novel),
|
||||
);
|
||||
};
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.NOVEL_AND_CHAPTERS,
|
||||
subtasks: subtasks,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreCategory = (
|
||||
parentId: string,
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
exists(BackupDataFileName.CATEGORY, false, parentId).then(file => {
|
||||
if (file) {
|
||||
return readFile(file, 'json').then((categories: BackupCategory[]) => {
|
||||
const subtasks = categories.map(category => {
|
||||
return async () => _restoreCategory(category);
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.CATEGORY,
|
||||
subtasks: subtasks,
|
||||
} as RestoreTask;
|
||||
});
|
||||
}
|
||||
return {
|
||||
taskType: TaskType.CATEGORY,
|
||||
subtasks: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const retoreDownload = (
|
||||
parentId: string,
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
readDir(parentId).then(files => {
|
||||
return {
|
||||
taskType: TaskType.DOWNLOAD,
|
||||
subtasks: files.map(file => () => readFile(file, 'media')),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreSetting = (
|
||||
parentId: string,
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
exists(BackupDataFileName.SETTING, false, parentId).then(file => {
|
||||
if (file) {
|
||||
return readFile(file, 'json').then(state => {
|
||||
const subtask = async () => {
|
||||
store.dispatch({
|
||||
type: RESTORE_NOVEL_STATE,
|
||||
payload: state.novelReducer,
|
||||
});
|
||||
store.dispatch(restorePluginState(state.pluginsReducer));
|
||||
store.dispatch(restorePreferenceState(state.preferenceReducer));
|
||||
store.dispatch(restoreSettingsState(state.settingsReducerV1));
|
||||
store.dispatch(restoreSettingsStateV2(state.settingsReducerV2));
|
||||
store.dispatch({
|
||||
type: RESTORE_TRACKER_STATE,
|
||||
payload: state.trackerReducer,
|
||||
});
|
||||
store.dispatch({
|
||||
type: RESTORE_UPDATE_STATE,
|
||||
payload: state.updatesReducer,
|
||||
});
|
||||
store.dispatch({
|
||||
type: RESTORE_DOWNLOADS_STATE,
|
||||
payload: state.downloadsReducer,
|
||||
});
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.SETTING,
|
||||
subtasks: [subtask],
|
||||
} as RestoreTask;
|
||||
});
|
||||
}
|
||||
return {
|
||||
taskType: TaskType.SETTING,
|
||||
subtasks: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreTheme = (
|
||||
parentId: string,
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
exists(BackupDataFileName.THEME, false, parentId).then(file => {
|
||||
if (file) {
|
||||
return readFile(file, 'json').then(theme => {
|
||||
const subtask = async () => {
|
||||
for (let key of [
|
||||
'APP_THEME',
|
||||
'AMOLED_BLACK',
|
||||
'CUSTOM_ACCENT_COLOR',
|
||||
]) {
|
||||
if (key in theme) {
|
||||
MMKVStorage.set(key, theme[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.THEME,
|
||||
subtasks: [subtask],
|
||||
} as RestoreTask;
|
||||
});
|
||||
}
|
||||
return {
|
||||
taskType: TaskType.THEME,
|
||||
subtasks: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -52,7 +52,7 @@ interface TaskData {
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export const restoreBackup = async (filePath: string) => {
|
||||
export const restoreBackup = async (filePath?: string) => {
|
||||
try {
|
||||
const backup = await DocumentPicker.getDocumentAsync({
|
||||
copyToCacheDirectory: false,
|
||||
@@ -1,264 +1,175 @@
|
||||
import { txnErrorCallback } from '@database/utils/helpers';
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import RNFS, { ReadDirItem } from 'react-native-fs';
|
||||
import { appVersion } from '@utils/versionUtils';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { store } from '@redux/store';
|
||||
import {
|
||||
DataFilePath,
|
||||
DataFolderPath,
|
||||
RequestPackage,
|
||||
BackupTask,
|
||||
TaskType,
|
||||
} from './types';
|
||||
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import {
|
||||
AppDownloadFolder,
|
||||
NovelDownloadFolder,
|
||||
} from '@utils/constants/download';
|
||||
import { walkDir } from '../utils';
|
||||
import {
|
||||
BackupPackage,
|
||||
BackupTask,
|
||||
BackupDataFileName,
|
||||
TaskType,
|
||||
} from '../types';
|
||||
import {
|
||||
getAllNovels,
|
||||
getNovelsWithCustomCover,
|
||||
} from '@database/queries/NovelQueries';
|
||||
import { getChapters } from '@database/queries/ChapterQueries';
|
||||
import {
|
||||
getAllNovelCategories,
|
||||
getCategoriesFromDb,
|
||||
} from '@database/queries/CategoryQueries';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
|
||||
export const versionTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
const requestPackage: RequestPackage = {
|
||||
taskType: TaskType.Version,
|
||||
content: {
|
||||
version: appVersion,
|
||||
},
|
||||
relative_path: DataFilePath.Version,
|
||||
};
|
||||
resolve({
|
||||
taskType: TaskType.Version,
|
||||
subtasks: [async () => requestPackage],
|
||||
});
|
||||
});
|
||||
export const versionTask = async (
|
||||
folderTree: string[],
|
||||
): Promise<BackupTask> => {
|
||||
const backupPackage: BackupPackage = {
|
||||
folderTree,
|
||||
content: JSON.stringify({
|
||||
version: appVersion,
|
||||
}),
|
||||
name: BackupDataFileName.VERSION,
|
||||
mimeType: 'application/json',
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.VERSION,
|
||||
subtasks: [async () => backupPackage],
|
||||
};
|
||||
};
|
||||
|
||||
export const categoryTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT * FROM Category',
|
||||
[],
|
||||
(txObj, { rows }) => {
|
||||
const requestPackage: RequestPackage = {
|
||||
taskType: TaskType.Category,
|
||||
content: rows._array,
|
||||
relative_path: DataFilePath.Category,
|
||||
};
|
||||
resolve({
|
||||
taskType: TaskType.Category,
|
||||
subtasks: [async () => requestPackage],
|
||||
} as BackupTask);
|
||||
},
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const novelTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT * FROM Novel',
|
||||
[],
|
||||
(txObj, { rows }) => {
|
||||
const requestPackage: RequestPackage = {
|
||||
taskType: TaskType.Novel,
|
||||
content: rows._array.map((novel: NovelInfo) => {
|
||||
if (novel.cover && !novel.cover.startsWith('http')) {
|
||||
novel.cover = `${NovelDownloadFolder}/${novel.pluginId}/${novel.id}/Cover.jpg`;
|
||||
}
|
||||
return novel;
|
||||
}),
|
||||
relative_path: DataFilePath.Novel,
|
||||
};
|
||||
resolve({
|
||||
taskType: TaskType.Novel,
|
||||
subtasks: [async () => requestPackage],
|
||||
} as BackupTask);
|
||||
},
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const novelCoverTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT * FROM Novel WHERE cover NOT LIKE "http%"',
|
||||
[],
|
||||
(txObj, { rows }) => {
|
||||
const subtasks = rows._array
|
||||
.filter((novel: NovelInfo) => novel.cover)
|
||||
.map((novel: NovelInfo) => {
|
||||
const subtask = async () => {
|
||||
let base64 = '';
|
||||
if (novel.cover) {
|
||||
base64 = await RNFS.readFile(novel.cover, 'base64');
|
||||
}
|
||||
return {
|
||||
taskType: TaskType.NovelCover,
|
||||
content: base64,
|
||||
encoding: 'base64',
|
||||
relative_path: `${novel.pluginId}/${novel.id}/Cover.jpg`,
|
||||
} as RequestPackage;
|
||||
};
|
||||
return subtask;
|
||||
});
|
||||
resolve({
|
||||
taskType: TaskType.NovelCover,
|
||||
subtasks: subtasks,
|
||||
} as BackupTask);
|
||||
},
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const novelCategoryTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT * FROM NovelCategory',
|
||||
[],
|
||||
(txObj, { rows }) => {
|
||||
const requestPackage: RequestPackage = {
|
||||
taskType: TaskType.NovelCategory,
|
||||
content: rows._array,
|
||||
relative_path: DataFilePath.NovelCatgory,
|
||||
};
|
||||
resolve({
|
||||
taskType: TaskType.NovelCategory,
|
||||
subtasks: [async () => requestPackage],
|
||||
} as BackupTask);
|
||||
},
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getChaptersByNovelId = (novelId: number): Promise<ChapterInfo[]> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT * FROM Chapter WHERE novelId = ?',
|
||||
[novelId],
|
||||
(txObj, { rows }) => resolve(rows._array),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const chapterTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
'SELECT id FROM Novel',
|
||||
[],
|
||||
(txObj, { rows }) => {
|
||||
const subtasks = rows._array.map((novel: NovelInfo) => {
|
||||
const subtask = async () => {
|
||||
const chapters = await getChaptersByNovelId(novel.id);
|
||||
const requestPackage: RequestPackage = {
|
||||
taskType: TaskType.Chapter,
|
||||
content: chapters,
|
||||
relative_path: `${DataFolderPath.Chapter}/${novel.id}.json`,
|
||||
};
|
||||
return requestPackage;
|
||||
};
|
||||
return subtask;
|
||||
});
|
||||
resolve({ taskType: TaskType.Chapter, subtasks: subtasks });
|
||||
},
|
||||
txnErrorCallback,
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const walkDir = async (items: ReadDirItem[]) => {
|
||||
let paths: string[] = [];
|
||||
for (let item of items) {
|
||||
if (item.isFile()) {
|
||||
paths.push(item.path);
|
||||
} else {
|
||||
const _items = await RNFS.readDir(item.path);
|
||||
paths = paths.concat(await walkDir(_items));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const downloadTask = (): Promise<BackupTask> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
let paths: string[] = [];
|
||||
if (await RNFS.exists(AppDownloadFolder)) {
|
||||
const items = await RNFS.readDir(AppDownloadFolder);
|
||||
paths = await walkDir(items);
|
||||
export const novelTask = async (folderTree: string[]): Promise<BackupTask> => {
|
||||
return getAllNovels().then(novels => {
|
||||
const subtasks = novels.map(novel => {
|
||||
if (novel.cover && !novel.cover.startsWith('http')) {
|
||||
novel.cover = `file:///${NovelDownloadFolder}/${novel.pluginId}/${novel.id}/cover.png`;
|
||||
}
|
||||
const subtasks = paths.map(path => {
|
||||
const subtask = async () => {
|
||||
const base64 = await RNFS.readFile(path, 'base64');
|
||||
return (): Promise<BackupPackage> =>
|
||||
getChapters(novel.id).then(chapters => {
|
||||
return {
|
||||
taskType: TaskType.Download,
|
||||
encoding: 'base64',
|
||||
content: base64,
|
||||
relative_path: path.replace(AppDownloadFolder + '/', ''),
|
||||
} as RequestPackage;
|
||||
};
|
||||
return subtask;
|
||||
});
|
||||
resolve({ taskType: TaskType.Download, subtasks: subtasks });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const settingTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
const state = store.getState();
|
||||
state.trackerReducer = {
|
||||
'tracker': null,
|
||||
'trackedNovels': [],
|
||||
folderTree: folderTree,
|
||||
name: novel.id + '.json',
|
||||
content: JSON.stringify({
|
||||
chapters: chapters,
|
||||
...novel,
|
||||
}),
|
||||
mimeType: 'application/json',
|
||||
};
|
||||
});
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.NOVEL_AND_CHAPTERS,
|
||||
subtasks: subtasks,
|
||||
};
|
||||
const requestPackage = {
|
||||
taskType: TaskType.Setting,
|
||||
content: state,
|
||||
relative_path: DataFilePath.Setting,
|
||||
} as RequestPackage;
|
||||
resolve({
|
||||
taskType: TaskType.Setting,
|
||||
subtasks: [async () => requestPackage],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const themeTask = (): Promise<BackupTask> => {
|
||||
return new Promise(resolve => {
|
||||
const APP_THEME = MMKVStorage.getString('APP_THEME');
|
||||
const AMOLED_BLACK = MMKVStorage.getBoolean('AMOLED_BLACK');
|
||||
const CUSTOM_ACCENT_COLOR = MMKVStorage.getString('CUSTOM_ACCENT_COLOR');
|
||||
const requestPackage = {
|
||||
taskType: TaskType.Theme,
|
||||
content: {
|
||||
APP_THEME,
|
||||
AMOLED_BLACK,
|
||||
CUSTOM_ACCENT_COLOR,
|
||||
},
|
||||
relative_path: DataFilePath.Theme,
|
||||
} as RequestPackage;
|
||||
resolve({
|
||||
taskType: TaskType.Theme,
|
||||
subtasks: [async () => requestPackage],
|
||||
});
|
||||
export const novelCoverTask = (folderTree: string[]): Promise<BackupTask> => {
|
||||
return getNovelsWithCustomCover().then(novels => {
|
||||
return {
|
||||
taskType: TaskType.NOVEL_COVER,
|
||||
subtasks: novels.map(novel => {
|
||||
return async (): Promise<BackupPackage> => {
|
||||
return {
|
||||
folderTree,
|
||||
name: `${NovelDownloadFolder}/${novel.pluginId}/${novel.id}/cover.png`.replace(
|
||||
AppDownloadFolder + '/',
|
||||
'',
|
||||
),
|
||||
mimeType: 'image/png',
|
||||
content: novel.cover || '',
|
||||
};
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const categoryTask = (folderTree: string[]): Promise<BackupTask> => {
|
||||
return getCategoriesFromDb().then(categories => {
|
||||
const task = async (): Promise<BackupPackage> => {
|
||||
return getAllNovelCategories().then(novelCategories => {
|
||||
return {
|
||||
folderTree,
|
||||
name: BackupDataFileName.CATEGORY,
|
||||
mimeType: 'application/json',
|
||||
content: JSON.stringify(
|
||||
categories.map(category => {
|
||||
return {
|
||||
...category,
|
||||
novelIds: novelCategories
|
||||
.filter(nc => nc.categoryId === category.id)
|
||||
.map(nc => nc.novelId),
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.CATEGORY,
|
||||
subtasks: [task],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const downloadTask = (folderTree: string[]): Promise<BackupTask> => {
|
||||
return walkDir(AppDownloadFolder).then(items => {
|
||||
return {
|
||||
taskType: TaskType.DOWNLOAD,
|
||||
subtasks: items.map(item => {
|
||||
return async (): Promise<BackupPackage> => {
|
||||
return {
|
||||
folderTree,
|
||||
name: item.path.replace(AppDownloadFolder + '/', ''),
|
||||
content: item.uri,
|
||||
mimeType: item.mimeType,
|
||||
};
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const settingTask = async (
|
||||
folderTree: string[],
|
||||
): Promise<BackupTask> => {
|
||||
const state = store.getState();
|
||||
state.trackerReducer = {
|
||||
'tracker': null,
|
||||
'trackedNovels': [],
|
||||
};
|
||||
const backupPackage: BackupPackage = {
|
||||
folderTree,
|
||||
name: BackupDataFileName.SETTING,
|
||||
mimeType: 'application/json',
|
||||
content: JSON.stringify(state),
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.SETTING,
|
||||
subtasks: [async () => backupPackage],
|
||||
};
|
||||
};
|
||||
|
||||
export const themeTask = async (folderTree: string[]): Promise<BackupTask> => {
|
||||
const APP_THEME = MMKVStorage.getString('APP_THEME');
|
||||
const AMOLED_BLACK = MMKVStorage.getBoolean('AMOLED_BLACK');
|
||||
const CUSTOM_ACCENT_COLOR = MMKVStorage.getString('CUSTOM_ACCENT_COLOR');
|
||||
|
||||
const backupPackage: BackupPackage = {
|
||||
folderTree,
|
||||
name: BackupDataFileName.THEME,
|
||||
mimeType: 'application/json',
|
||||
content: JSON.stringify({
|
||||
APP_THEME,
|
||||
AMOLED_BLACK,
|
||||
CUSTOM_ACCENT_COLOR,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.THEME,
|
||||
subtasks: [async () => backupPackage],
|
||||
};
|
||||
};
|
||||
|
||||
+152
-270
@@ -1,328 +1,210 @@
|
||||
import { showToast } from '@hooks/showToast';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { sleep } from '@utils/sleep';
|
||||
import BackgroundService from 'react-native-background-actions';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { upload } from '@api/remote';
|
||||
import {
|
||||
categoryTask,
|
||||
novelTask,
|
||||
novelCoverTask,
|
||||
novelCategoryTask,
|
||||
chapterTask,
|
||||
downloadTask,
|
||||
novelCoverTask,
|
||||
novelTask,
|
||||
settingTask,
|
||||
themeTask,
|
||||
versionTask,
|
||||
} from './backupTasks';
|
||||
|
||||
import {
|
||||
checkAppVersion,
|
||||
restoreCategory,
|
||||
restoreChapter,
|
||||
restoreDownload,
|
||||
restoreNovel,
|
||||
restoreNovelCategory,
|
||||
restoreSetting,
|
||||
restoreTheme,
|
||||
retoreDownload,
|
||||
} from './restoreTasks';
|
||||
import {
|
||||
RequestType,
|
||||
RequestPackage,
|
||||
ResponsePackage,
|
||||
BackupTask,
|
||||
} from './types';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import BackgroundService from 'react-native-background-actions';
|
||||
import { sleep } from '@utils/sleep';
|
||||
|
||||
export const remoteBackup = async (ipv4: string, port: string) => {
|
||||
interface TaskData {
|
||||
delay: number;
|
||||
host: string;
|
||||
backupFolder: string;
|
||||
}
|
||||
|
||||
const remoteBackupAction = async (taskData?: TaskData) => {
|
||||
try {
|
||||
const options = {
|
||||
taskName: 'Remote Backup',
|
||||
taskTitle: 'Remote Backup',
|
||||
taskDesc: 'Preparing',
|
||||
taskIcon: { name: 'notification_icon', type: 'drawable' },
|
||||
color: '#00adb5',
|
||||
parameters: { delay: 1000 },
|
||||
linkingURI: 'lnreader://updates',
|
||||
};
|
||||
if (!taskData) {
|
||||
throw new Error('No data provided');
|
||||
}
|
||||
const { delay, backupFolder, host } = taskData;
|
||||
await sleep(delay);
|
||||
|
||||
const remoteBackupBackgroundAction = async (taskData: any) => {
|
||||
const taskList: Array<() => Promise<BackupTask>> = [
|
||||
versionTask,
|
||||
categoryTask,
|
||||
novelTask,
|
||||
novelCoverTask,
|
||||
novelCategoryTask,
|
||||
chapterTask,
|
||||
downloadTask,
|
||||
settingTask,
|
||||
themeTask,
|
||||
];
|
||||
const state = {
|
||||
error: false,
|
||||
taskList: taskList,
|
||||
subtaskList: [] as Array<() => Promise<RequestPackage>>,
|
||||
currentTaskIndex: -1,
|
||||
currentSubtaskIndex: 0,
|
||||
taskType: '',
|
||||
finised: false,
|
||||
};
|
||||
const getNextRequestData = async () => {
|
||||
// get next task if current task is done (all subtasks is finised)
|
||||
while (
|
||||
state.currentTaskIndex < state.taskList.length - 1 &&
|
||||
state.currentSubtaskIndex === state.subtaskList.length
|
||||
) {
|
||||
state.currentTaskIndex += 1;
|
||||
const { taskType, subtasks } = await state.taskList[
|
||||
state.currentTaskIndex
|
||||
]();
|
||||
state.taskType = taskType;
|
||||
state.subtaskList = subtasks;
|
||||
state.currentSubtaskIndex = 0;
|
||||
}
|
||||
// the last task is finished
|
||||
if (state.currentSubtaskIndex === state.subtaskList.length) {
|
||||
return undefined;
|
||||
}
|
||||
const requestData = await state.subtaskList[
|
||||
state.currentSubtaskIndex
|
||||
]();
|
||||
state.currentSubtaskIndex += 1;
|
||||
const dataFolder = [backupFolder, 'Data'];
|
||||
const downloadFolder = [backupFolder, 'Download'];
|
||||
const novelFolder = [backupFolder, 'Data', 'NovelAndChapters'];
|
||||
|
||||
const taskList = [
|
||||
versionTask(dataFolder),
|
||||
novelTask(novelFolder),
|
||||
novelCoverTask(downloadFolder),
|
||||
categoryTask(dataFolder),
|
||||
downloadTask(downloadFolder),
|
||||
settingTask(dataFolder),
|
||||
themeTask(dataFolder),
|
||||
];
|
||||
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
const { taskType, subtasks } = await taskList[i];
|
||||
for (let j = 0; j < subtasks.length; j++) {
|
||||
await BackgroundService.updateNotification({
|
||||
taskDesc: `Backup ${state.taskType} (${state.currentSubtaskIndex}/${state.subtaskList.length})`,
|
||||
taskDesc: `Backup ${taskType} (${j}/${subtasks.length})`,
|
||||
progressBar: {
|
||||
max: state.subtaskList.length,
|
||||
value: state.currentSubtaskIndex,
|
||||
max: subtasks.length,
|
||||
value: j,
|
||||
},
|
||||
});
|
||||
return requestData;
|
||||
};
|
||||
|
||||
const ws = new WebSocket(`ws://${ipv4}:${port}`);
|
||||
ws.onopen = () => {
|
||||
showToast('Connected');
|
||||
getNextRequestData().then(data => {
|
||||
if (data) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: RequestType.Backup,
|
||||
data: data,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
ws.onerror = e => {
|
||||
state.error = true;
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Backup Interrupted',
|
||||
body: e.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
ws.close();
|
||||
};
|
||||
ws.onmessage = async e => {
|
||||
const response = JSON.parse(e.data) as ResponsePackage;
|
||||
if (response.success) {
|
||||
getNextRequestData()
|
||||
.then(data => {
|
||||
if (data) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: RequestType.Backup,
|
||||
data: data,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'Metadata' }));
|
||||
ws.close();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
state.error = true;
|
||||
ws.close();
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Backup Interrupted',
|
||||
body: error.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
state.error = true;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
ws.onclose = async () => {
|
||||
if (!state.error) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote backup',
|
||||
body: 'Done!',
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
.then(() => subtasks[j]())
|
||||
.then(backupPackage => upload(host, backupPackage))
|
||||
.then(() => sleep(delay))
|
||||
.catch(error => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
state.finised = true;
|
||||
};
|
||||
|
||||
// this keeps Background service running
|
||||
while (!state.finised) {
|
||||
await sleep(taskData.delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
await BackgroundService.start(remoteBackupBackgroundAction, options);
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Self Host Backup',
|
||||
body: 'Done',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (BackgroundService.isRunning()) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Backup Interruped',
|
||||
title: 'Self Host Backup Interruped',
|
||||
body: error.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
}
|
||||
} finally {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
}
|
||||
};
|
||||
|
||||
export const remoteRestore = async (ipv4: string, port: string) => {
|
||||
export const createBackup = async (host: string, backupFolder: string) => {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', true);
|
||||
try {
|
||||
const options = {
|
||||
taskName: 'Remote Restore',
|
||||
taskTitle: 'Remote Restore',
|
||||
return BackgroundService.start(remoteBackupAction, {
|
||||
taskName: 'Self Host Backup',
|
||||
taskTitle: 'Self Host Backup',
|
||||
taskDesc: 'Preparing',
|
||||
taskIcon: { name: 'notification_icon', type: 'drawable' },
|
||||
color: '#00adb5',
|
||||
parameters: { delay: 1000 },
|
||||
parameters: { delay: 200, backupFolder, host },
|
||||
linkingURI: 'lnreader://updates',
|
||||
};
|
||||
});
|
||||
} catch (e: any) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Self Host Backup Interruped',
|
||||
body: e.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const remoteRestoreBackgroundAction = async (taskData: any) => {
|
||||
const taskMap: Record<
|
||||
string,
|
||||
(responsePackage: ResponsePackage) => Promise<void>
|
||||
> = {
|
||||
'Version': checkAppVersion,
|
||||
'Category': restoreCategory,
|
||||
'Novel': restoreNovel,
|
||||
'NovelCategory': restoreNovelCategory,
|
||||
'Chapter': restoreChapter,
|
||||
'Download': restoreDownload,
|
||||
'Setting': restoreSetting,
|
||||
'Theme': restoreTheme,
|
||||
};
|
||||
const state = {
|
||||
error: false,
|
||||
index: 0,
|
||||
finised: false,
|
||||
};
|
||||
const remoteRestoreAction = async (taskData?: TaskData) => {
|
||||
try {
|
||||
if (!taskData) {
|
||||
throw new Error('No data provided');
|
||||
}
|
||||
const { delay, backupFolder, host } = taskData;
|
||||
await sleep(delay);
|
||||
|
||||
const ws = new WebSocket(`ws://${ipv4}:${port}`);
|
||||
ws.onopen = async () => {
|
||||
showToast('Connected');
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'Restore',
|
||||
data: { index: state.index },
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onerror = (e: any) => {
|
||||
state.error = true;
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Restore Interrupted',
|
||||
body: e.message,
|
||||
const dataFolder = [backupFolder, 'Data'];
|
||||
const downloadFolder = [backupFolder, 'Download'];
|
||||
const novelFolder = [backupFolder, 'Data', 'NovelAndChapters'];
|
||||
|
||||
if (!dataFolder || !downloadFolder || !novelFolder) {
|
||||
throw new Error('Invalid backup folder');
|
||||
}
|
||||
|
||||
const taskList = [
|
||||
restoreNovel(host, novelFolder),
|
||||
restoreCategory(host, dataFolder),
|
||||
retoreDownload(host, downloadFolder),
|
||||
restoreSetting(host, dataFolder),
|
||||
restoreTheme(host, dataFolder),
|
||||
];
|
||||
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
const { taskType, subtasks } = await taskList[i]();
|
||||
for (let j = 0; j < subtasks.length; j++) {
|
||||
await BackgroundService.updateNotification({
|
||||
taskDesc: `Restore ${taskType} (${j}/${subtasks.length})`,
|
||||
progressBar: {
|
||||
max: subtasks.length,
|
||||
value: j,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
ws.close();
|
||||
};
|
||||
ws.onmessage = async e => {
|
||||
const response = JSON.parse(e.data) as ResponsePackage;
|
||||
if (response.success) {
|
||||
state.index += 1;
|
||||
await BackgroundService.updateNotification({
|
||||
taskDesc: `Restore ${response.taskType} (${state.index}/${response.total})`,
|
||||
progressBar: {
|
||||
max: response.total as number,
|
||||
value: state.index,
|
||||
},
|
||||
})
|
||||
.then(() => subtasks[j]())
|
||||
.then(() => sleep(delay))
|
||||
.catch(error => {
|
||||
throw error;
|
||||
});
|
||||
if (response.taskType && response.taskType in taskMap) {
|
||||
taskMap[response.taskType](response)
|
||||
.then(() => {
|
||||
if (state.index === response.total) {
|
||||
state.finised = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'Restore',
|
||||
data: {
|
||||
finished: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
ws.close();
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'Restore',
|
||||
data: { index: state.index },
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
state.error = true;
|
||||
ws.close();
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Restore Interrupted',
|
||||
body: error.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
state.error = true;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
ws.onclose = async () => {
|
||||
if (!state.error) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Restore',
|
||||
body: 'Done!',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
}
|
||||
state.finised = true;
|
||||
};
|
||||
|
||||
// this keeps Background service running
|
||||
while (!state.finised) {
|
||||
await sleep(taskData.delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
await BackgroundService.start(remoteRestoreBackgroundAction, options);
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Self Host Restore',
|
||||
body: 'Done',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
if (BackgroundService.isRunning()) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Remote Backup Interrupted',
|
||||
title: 'Self Host Restore Interruped',
|
||||
body: error.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
}
|
||||
} finally {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
}
|
||||
};
|
||||
|
||||
export const remoteRestore = async (host: string, backupFolder: string) => {
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', true);
|
||||
try {
|
||||
return BackgroundService.start(remoteRestoreAction, {
|
||||
taskName: 'Self Host Restore',
|
||||
taskTitle: 'Self Host Restore',
|
||||
taskDesc: 'Preparing',
|
||||
taskIcon: { name: 'notification_icon', type: 'drawable' },
|
||||
color: '#00adb5',
|
||||
parameters: { delay: 200, backupFolder, host },
|
||||
linkingURI: 'lnreader://updates',
|
||||
});
|
||||
} catch (e: any) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Self Host Restore Interruped',
|
||||
body: e.message,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
await BackgroundService.stop();
|
||||
MMKVStorage.set('HAS_BACKGROUND_TASK', false);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,178 +1,153 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
import { BackupDataFileName, RestoreTask, TaskType } from '../types';
|
||||
import { BackupCategory, BackupNovel } from '@database/types';
|
||||
import { _restoreNovelAndChapters } from '@database/queries/NovelQueries';
|
||||
import { _restoreCategory } from '@database/queries/CategoryQueries';
|
||||
import { store } from '@redux/store';
|
||||
import { ResponsePackage } from './types';
|
||||
import { RESTORE_NOVEL_STATE } from '@redux/novel/novel.types';
|
||||
import { restorePluginState } from '@redux/plugins/pluginsSlice';
|
||||
import { restorePreferenceState } from '@redux/preferences/preferencesSlice';
|
||||
import { restoreSettingsState } from '@redux/settings/settingsSliceV1';
|
||||
import { restoreSettingsState as restoreSettingsStateV2 } from '@redux/settings/settingsSliceV2';
|
||||
import { RESTORE_TRACKER_STATE } from '@redux/tracker/tracker.types';
|
||||
import { RESTORE_UPDATE_STATE } from '@redux/updates/updates.types';
|
||||
import { RESTORE_DOWNLOADS_STATE } from '@redux/downloads/donwloads.types';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { download, exists, getJson, list } from '@api/remote';
|
||||
|
||||
import { createCategoryTriggerQuery } from '@database/tables/CategoryTable';
|
||||
import { AppDownloadFolder } from '@utils/constants/download';
|
||||
import { restoreSettingsState } from '@redux/settings/settingsSliceV1';
|
||||
|
||||
const db = SQLite.openDatabase('lnreader.db');
|
||||
|
||||
const insertObject = (record: any, table: string): Promise<void> => {
|
||||
const fields = Object.keys(record).join(', ');
|
||||
const values = Object.keys(record)
|
||||
.map(() => '?')
|
||||
.join(', ');
|
||||
const query = `INSERT OR IGNORE INTO ${table} (${fields}) VALUES (${values})`;
|
||||
return new Promise((resolve, reject) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(
|
||||
query,
|
||||
Object.values(record),
|
||||
() => resolve(),
|
||||
error => {
|
||||
reject(error);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const insertTable = (records: any[], table: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(`DELETE FROM ${table}`, [], () => {
|
||||
Promise.all(records.map((record: any) => insertObject(record, table)))
|
||||
.then(() => resolve())
|
||||
.catch(error => reject(error));
|
||||
export const restoreNovel = (
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
list(host, folderTree).then(files => {
|
||||
const subtasks = files.map(file => {
|
||||
return (): Promise<void> => {
|
||||
return getJson(host, folderTree, file).then((novel: BackupNovel) =>
|
||||
_restoreNovelAndChapters(novel),
|
||||
);
|
||||
};
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.NOVEL_AND_CHAPTERS,
|
||||
subtasks: subtasks,
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const checkAppVersion = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
responsePackage; // currently there's nothing to do
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreCategory = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// this trigger could make sort mess up
|
||||
db.transaction(tx => {
|
||||
tx.executeSql('DROP TRIGGER if EXISTS add_category');
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
exists(host, folderTree, BackupDataFileName.CATEGORY).then(existed => {
|
||||
if (existed) {
|
||||
return getJson(host, folderTree, BackupDataFileName.CATEGORY).then(
|
||||
(categories: BackupCategory[]) => {
|
||||
const subtasks = categories.map(category => {
|
||||
return async () => _restoreCategory(category);
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.CATEGORY,
|
||||
subtasks: subtasks,
|
||||
} as RestoreTask;
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
taskType: TaskType.CATEGORY,
|
||||
subtasks: [],
|
||||
};
|
||||
});
|
||||
await insertTable(responsePackage.content, 'Category').catch(error =>
|
||||
reject(error),
|
||||
);
|
||||
db.transaction(tx => {
|
||||
tx.executeSql(createCategoryTriggerQuery);
|
||||
};
|
||||
|
||||
export const retoreDownload = (
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
list(host, folderTree).then(files => {
|
||||
return {
|
||||
taskType: TaskType.DOWNLOAD,
|
||||
subtasks: files.map(file => () => download(host, folderTree, file)),
|
||||
};
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreNovel = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.transaction(async tx => {
|
||||
tx.executeSql(
|
||||
'DELETE FROM Chapter',
|
||||
[],
|
||||
() =>
|
||||
insertTable(responsePackage.content, 'Novel').then(() => resolve()),
|
||||
error => {
|
||||
reject(error);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreNovelCategory = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
insertTable(responsePackage.content, 'NovelCategory')
|
||||
.then(() => resolve())
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreChapter = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chapters: any[] = responsePackage.content;
|
||||
Promise.all(chapters.map(chapter => insertObject(chapter, 'Chapter')))
|
||||
.then(() => resolve())
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreDownload = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const realPath = `${AppDownloadFolder}/${responsePackage.relative_path}`;
|
||||
const folderPath = realPath.split('/').slice(0, -1).join('/');
|
||||
RNFS.exists(folderPath)
|
||||
.then(exists => {
|
||||
if (!exists) {
|
||||
return RNFS.mkdir(folderPath);
|
||||
}
|
||||
return new Promise(_resolve => _resolve(null));
|
||||
})
|
||||
.then(() => RNFS.writeFile(realPath, responsePackage.content, 'base64'))
|
||||
.then(() => resolve())
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreSetting = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
const state = responsePackage.content;
|
||||
store.dispatch({ type: RESTORE_NOVEL_STATE, payload: state.novelReducer });
|
||||
store.dispatch(restorePluginState(state.pluginsReducer));
|
||||
store.dispatch(restorePreferenceState(state.preferenceReducer));
|
||||
store.dispatch(restoreSettingsState(state.settingsReducerV1));
|
||||
store.dispatch(restoreSettingsStateV2(state.settingsReducerV2));
|
||||
store.dispatch({
|
||||
type: RESTORE_TRACKER_STATE,
|
||||
payload: state.trackerReducer,
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
exists(host, folderTree, BackupDataFileName.SETTING).then(existed => {
|
||||
if (existed) {
|
||||
return getJson(host, folderTree, BackupDataFileName.SETTING).then(
|
||||
state => {
|
||||
const subtask = async () => {
|
||||
store.dispatch({
|
||||
type: RESTORE_NOVEL_STATE,
|
||||
payload: state.novelReducer,
|
||||
});
|
||||
store.dispatch(restorePluginState(state.pluginsReducer));
|
||||
store.dispatch(restorePreferenceState(state.preferenceReducer));
|
||||
store.dispatch(restoreSettingsState(state.settingsReducerV1));
|
||||
store.dispatch(restoreSettingsStateV2(state.settingsReducerV2));
|
||||
store.dispatch({
|
||||
type: RESTORE_TRACKER_STATE,
|
||||
payload: state.trackerReducer,
|
||||
});
|
||||
store.dispatch({
|
||||
type: RESTORE_UPDATE_STATE,
|
||||
payload: state.updatesReducer,
|
||||
});
|
||||
store.dispatch({
|
||||
type: RESTORE_DOWNLOADS_STATE,
|
||||
payload: state.downloadsReducer,
|
||||
});
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.SETTING,
|
||||
subtasks: [subtask],
|
||||
} as RestoreTask;
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
taskType: TaskType.SETTING,
|
||||
subtasks: [],
|
||||
};
|
||||
});
|
||||
store.dispatch({
|
||||
type: RESTORE_UPDATE_STATE,
|
||||
payload: state.updatesReducer,
|
||||
});
|
||||
store.dispatch({
|
||||
type: RESTORE_DOWNLOADS_STATE,
|
||||
payload: state.downloadsReducer,
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreTheme = (
|
||||
responsePackage: ResponsePackage,
|
||||
): Promise<void> => {
|
||||
return new Promise(resovle => {
|
||||
const theme = responsePackage.content;
|
||||
for (let key of ['APP_THEME', 'AMOLED_BLACK', 'CUSTOM_ACCENT_COLOR']) {
|
||||
if (key in theme) {
|
||||
MMKVStorage.set(key, theme[key]);
|
||||
host: string,
|
||||
folderTree: string[],
|
||||
): (() => Promise<RestoreTask>) => {
|
||||
return () =>
|
||||
exists(host, folderTree, BackupDataFileName.THEME).then(existed => {
|
||||
if (existed) {
|
||||
return getJson(host, folderTree, BackupDataFileName.THEME).then(
|
||||
theme => {
|
||||
const subtask = async () => {
|
||||
for (let key of [
|
||||
'APP_THEME',
|
||||
'AMOLED_BLACK',
|
||||
'CUSTOM_ACCENT_COLOR',
|
||||
]) {
|
||||
if (key in theme) {
|
||||
MMKVStorage.set(key, theme[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
taskType: TaskType.THEME,
|
||||
subtasks: [subtask],
|
||||
} as RestoreTask;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
resovle();
|
||||
});
|
||||
return {
|
||||
taskType: TaskType.THEME,
|
||||
subtasks: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
export enum DataFilePath {
|
||||
Version = 'Version.json',
|
||||
Category = 'Category.json',
|
||||
Novel = 'Novel.json',
|
||||
NovelCatgory = 'NovelCategory.json',
|
||||
Setting = 'Setting.json',
|
||||
Theme = 'Theme.json',
|
||||
}
|
||||
|
||||
export enum DataFolderPath {
|
||||
Chapter = 'Chapters',
|
||||
}
|
||||
|
||||
// both above are in /Backup/Data
|
||||
|
||||
export enum RequestType {
|
||||
Backup = 'Backup',
|
||||
Restore = 'Restore',
|
||||
}
|
||||
|
||||
export enum TaskType {
|
||||
Version = 'Version',
|
||||
Category = 'Category',
|
||||
Novel = 'Novel',
|
||||
NovelCover = 'NovelCover',
|
||||
NovelCategory = 'NovelCategory',
|
||||
Chapter = 'Chapter',
|
||||
Download = 'Download',
|
||||
Setting = 'Setting',
|
||||
Theme = 'Theme',
|
||||
}
|
||||
|
||||
export interface SocketPackage {
|
||||
content?: any;
|
||||
encoding?: string; // 'base64', 'utf-8', undifined -> content is json
|
||||
relative_path?: string | DataFilePath;
|
||||
}
|
||||
|
||||
export interface RequestPackage extends SocketPackage {
|
||||
taskType: TaskType;
|
||||
}
|
||||
|
||||
export interface ResponsePackage extends SocketPackage {
|
||||
success: boolean;
|
||||
message: any;
|
||||
total?: number; // total tasks
|
||||
taskType?: TaskType;
|
||||
}
|
||||
|
||||
export interface BackupTask {
|
||||
taskType: TaskType; // for notification
|
||||
|
||||
// Images or ChapterText could be large, so transfer it one by one
|
||||
subtasks: Array<() => Promise<RequestPackage>>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export enum BackupFolderName {
|
||||
DATA = 'Data',
|
||||
DOWNLOAD = 'Download',
|
||||
NOVEL_AND_CHAPTERS = 'NovelAndChapters',
|
||||
}
|
||||
|
||||
export enum BackupDataFileName {
|
||||
VERSION = 'Version.json',
|
||||
CATEGORY = 'Category.json',
|
||||
SETTING = 'Setting.json',
|
||||
THEME = 'Theme.json',
|
||||
}
|
||||
|
||||
export enum TaskType {
|
||||
VERSION = 'Version',
|
||||
CATEGORY = 'Category',
|
||||
NOVEL_AND_CHAPTERS = 'Novel and Chapters',
|
||||
NOVEL_COVER = 'Novel Cover',
|
||||
DOWNLOAD = 'Download',
|
||||
SETTING = 'Setting',
|
||||
THEME = 'Theme',
|
||||
}
|
||||
|
||||
export interface BackupPackage {
|
||||
folderTree: string[]; // for Drive, it must be [parentId]
|
||||
content: string; // file text content or file uri
|
||||
name: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface RestoreTask {
|
||||
taskType: TaskType;
|
||||
subtasks: Array<() => Promise<void>>;
|
||||
}
|
||||
|
||||
export interface BackupTask {
|
||||
taskType: TaskType; // for notification
|
||||
subtasks: Array<() => Promise<BackupPackage>>;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
const mimeTypes = {
|
||||
// File Extension MIME Type
|
||||
'abs': 'audio/x-mpeg',
|
||||
'ai': 'application/postscript',
|
||||
'aif': 'audio/x-aiff',
|
||||
'aifc': 'audio/x-aiff',
|
||||
'aiff': 'audio/x-aiff',
|
||||
'aim': 'application/x-aim',
|
||||
'art': 'image/x-jg',
|
||||
'asf': 'video/x-ms-asf',
|
||||
'asx': 'video/x-ms-asf',
|
||||
'au': 'audio/basic',
|
||||
'avi': 'video/x-msvideo',
|
||||
'avx': 'video/x-rad-screenplay',
|
||||
'bcpio': 'application/x-bcpio',
|
||||
'bin': 'application/octet-stream',
|
||||
'bmp': 'image/bmp',
|
||||
'body': 'text/html',
|
||||
'cdf': 'application/x-cdf',
|
||||
'cer': 'application/pkix-cert',
|
||||
'class': 'application/java',
|
||||
'cpio': 'application/x-cpio',
|
||||
'csh': 'application/x-csh',
|
||||
'css': 'text/css',
|
||||
'dib': 'image/bmp',
|
||||
'doc': 'application/msword',
|
||||
'dtd': 'application/xml-dtd',
|
||||
'dv': 'video/x-dv',
|
||||
'dvi': 'application/x-dvi',
|
||||
'eot': 'application/vnd.ms-fontobject',
|
||||
'eps': 'application/postscript',
|
||||
'etx': 'text/x-setext',
|
||||
'exe': 'application/octet-stream',
|
||||
'gif': 'image/gif',
|
||||
'gtar': 'application/x-gtar',
|
||||
'gz': 'application/x-gzip',
|
||||
'hdf': 'application/x-hdf',
|
||||
'hqx': 'application/mac-binhex40',
|
||||
'htc': 'text/x-component',
|
||||
'htm': 'text/html',
|
||||
'html': 'text/html',
|
||||
'ief': 'image/ief',
|
||||
'jad': 'text/vnd.sun.j2me.app-descriptor',
|
||||
'jar': 'application/java-archive',
|
||||
'java': 'text/x-java-source',
|
||||
'jnlp': 'application/x-java-jnlp-file',
|
||||
'jpe': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'jpg': 'image/jpeg',
|
||||
'js': 'application/javascript',
|
||||
'jsf': 'text/plain',
|
||||
'json': 'application/json',
|
||||
'jspf': 'text/plain',
|
||||
'kar': 'audio/midi',
|
||||
'latex': 'application/x-latex',
|
||||
'm3u': 'audio/x-mpegurl',
|
||||
'mac': 'image/x-macpaint',
|
||||
'man': 'text/troff',
|
||||
'mathml': 'application/mathml+xml',
|
||||
'me': 'text/troff',
|
||||
'mid': 'audio/midi',
|
||||
'midi': 'audio/midi',
|
||||
'mif': 'application/x-mif',
|
||||
'mov': 'video/quicktime',
|
||||
'movie': 'video/x-sgi-movie',
|
||||
'mp1': 'audio/mpeg',
|
||||
'mp2': 'audio/mpeg',
|
||||
'mp3': 'audio/mpeg',
|
||||
'mp4': 'video/mp4',
|
||||
'mpa': 'audio/mpeg',
|
||||
'mpe': 'video/mpeg',
|
||||
'mpeg': 'video/mpeg',
|
||||
'mpega': 'audio/x-mpeg',
|
||||
'mpg': 'video/mpeg',
|
||||
'mpv2': 'video/mpeg2',
|
||||
'ms': 'application/x-wais-source',
|
||||
'nc': 'application/x-netcdf',
|
||||
'oda': 'application/oda',
|
||||
'odb': 'application/vnd.oasis.opendocument.database',
|
||||
'odc': 'application/vnd.oasis.opendocument.chart',
|
||||
'odf': 'application/vnd.oasis.opendocument.formula',
|
||||
'odg': 'application/vnd.oasis.opendocument.graphics',
|
||||
'odi': 'application/vnd.oasis.opendocument.image',
|
||||
'odm': 'application/vnd.oasis.opendocument.text-master',
|
||||
'odp': 'application/vnd.oasis.opendocument.presentation',
|
||||
'ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'odt': 'application/vnd.oasis.opendocument.text',
|
||||
'otg': 'application/vnd.oasis.opendocument.graphics-template',
|
||||
'oth': 'application/vnd.oasis.opendocument.text-web',
|
||||
'otp': 'application/vnd.oasis.opendocument.presentation-template',
|
||||
'ots': 'application/vnd.oasis.opendocument.spreadsheet-template',
|
||||
'ott': 'application/vnd.oasis.opendocument.text-template',
|
||||
'ogx': 'application/ogg',
|
||||
'ogv': 'video/ogg',
|
||||
'oga': 'audio/ogg',
|
||||
'ogg': 'audio/ogg',
|
||||
'otf': 'application/x-font-opentype',
|
||||
'spx': 'audio/ogg',
|
||||
'flac': 'audio/flac',
|
||||
'anx': 'application/annodex',
|
||||
'axa': 'audio/annodex',
|
||||
'axv': 'video/annodex',
|
||||
'xspf': 'application/xspf+xml',
|
||||
'pbm': 'image/x-portable-bitmap',
|
||||
'pct': 'image/pict',
|
||||
'pdf': 'application/pdf',
|
||||
'pgm': 'image/x-portable-graymap',
|
||||
'pic': 'image/pict',
|
||||
'pict': 'image/pict',
|
||||
'pls': 'audio/x-scpls',
|
||||
'png': 'image/png',
|
||||
'pnm': 'image/x-portable-anymap',
|
||||
'pnt': 'image/x-macpaint',
|
||||
'ppm': 'image/x-portable-pixmap',
|
||||
'ppt': 'application/vnd.ms-powerpoint',
|
||||
'pps': 'application/vnd.ms-powerpoint',
|
||||
'ps': 'application/postscript',
|
||||
'psd': 'image/vnd.adobe.photoshop',
|
||||
'qt': 'video/quicktime',
|
||||
'qti': 'image/x-quicktime',
|
||||
'qtif': 'image/x-quicktime',
|
||||
'ras': 'image/x-cmu-raster',
|
||||
'rdf': 'application/rdf+xml',
|
||||
'rgb': 'image/x-rgb',
|
||||
'rm': 'application/vnd.rn-realmedia',
|
||||
'roff': 'text/troff',
|
||||
'rtf': 'application/rtf',
|
||||
'rtx': 'text/richtext',
|
||||
'sfnt': 'application/font-sfnt',
|
||||
'sh': 'application/x-sh',
|
||||
'shar': 'application/x-shar',
|
||||
'sit': 'application/x-stuffit',
|
||||
'snd': 'audio/basic',
|
||||
'src': 'application/x-wais-source',
|
||||
'sv4cpio': 'application/x-sv4cpio',
|
||||
'sv4crc': 'application/x-sv4crc',
|
||||
'svg': 'image/svg+xml',
|
||||
'svgz': 'image/svg+xml',
|
||||
'swf': 'application/x-shockwave-flash',
|
||||
't': 'text/troff',
|
||||
'tar': 'application/x-tar',
|
||||
'tcl': 'application/x-tcl',
|
||||
'tex': 'application/x-tex',
|
||||
'texi': 'application/x-texinfo',
|
||||
'texinfo': 'application/x-texinfo',
|
||||
'tif': 'image/tiff',
|
||||
'tiff': 'image/tiff',
|
||||
'tr': 'text/troff',
|
||||
'tsv': 'text/tab-separated-values',
|
||||
'ttf': 'application/x-font-ttf',
|
||||
'txt': 'text/plain',
|
||||
'ulw': 'audio/basic',
|
||||
'ustar': 'application/x-ustar',
|
||||
'vxml': 'application/voicexml+xml',
|
||||
'xbm': 'image/x-xbitmap',
|
||||
'xht': 'application/xhtml+xml',
|
||||
'xhtml': 'application/xhtml+xml',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xml': 'application/xml',
|
||||
'xpm': 'image/x-xpixmap',
|
||||
'xsl': 'application/xml',
|
||||
'xslt': 'application/xslt+xml',
|
||||
'xul': 'application/vnd.mozilla.xul+xml',
|
||||
'xwd': 'image/x-xwindowdump',
|
||||
'vsd': 'application/vnd.visio',
|
||||
'wav': 'audio/x-wav',
|
||||
'wbmp': 'image/vnd.wap.wbmp',
|
||||
'wml': 'text/vnd.wap.wml',
|
||||
'wmlc': 'application/vnd.wap.wmlc',
|
||||
'wmls': 'text/vnd.wap.wmlsc',
|
||||
'wmlscriptc': 'application/vnd.wap.wmlscriptc',
|
||||
'wmv': 'video/x-ms-wmv',
|
||||
'woff': 'application/font-woff',
|
||||
'woff2': 'application/font-woff2',
|
||||
'wrl': 'model/vrml',
|
||||
'wspolicy': 'application/wspolicy+xml',
|
||||
'z': 'application/x-compress',
|
||||
'zip': 'application/zip',
|
||||
};
|
||||
|
||||
interface PathObject {
|
||||
name: string;
|
||||
uri: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
const getMimeType = (path: string) => {
|
||||
const extension = path.split('.').slice(-1)[0];
|
||||
for (let [k, v] of Object.entries(mimeTypes)) {
|
||||
if (k === extension) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return 'text/plain';
|
||||
};
|
||||
|
||||
export const walkDir = async (path: string) => {
|
||||
let paths: PathObject[] = [];
|
||||
const items = await RNFS.readDir(path).catch(() => []);
|
||||
for (let item of items) {
|
||||
if (item.isFile()) {
|
||||
paths.push({
|
||||
name: item.name,
|
||||
uri: 'file:///' + item.path,
|
||||
path: item.path,
|
||||
mimeType: getMimeType(item.path),
|
||||
});
|
||||
} else {
|
||||
paths = paths.concat(await walkDir(item.path));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
@@ -41,3 +41,16 @@ export const fetchFile = async (url: string, init: any) => {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchTimeout = async (
|
||||
url: string,
|
||||
init?: any,
|
||||
timeout: number = 5000,
|
||||
) => {
|
||||
const constroller = new AbortController();
|
||||
setTimeout(() => constroller.abort(), timeout);
|
||||
return fetch(url, {
|
||||
...init,
|
||||
signal: constroller.signal,
|
||||
});
|
||||
};
|
||||
|
||||
+2
-2
@@ -28,12 +28,12 @@
|
||||
"@services/*": ["src/services/*"],
|
||||
"@navigators/*": ["src/navigators/*"],
|
||||
"@native/*": ["src/native/*"],
|
||||
"@api/*": ["src/api/*"],
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"babel.config.js",
|
||||
"metro.config.js",
|
||||
"jest.config.js"
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user