From 96a5e9b52f882c242f721d8488b6c78327c9ee4a Mon Sep 17 00:00:00 2001 From: TecktonikMuffin Date: Thu, 28 Apr 2022 17:26:55 +1200 Subject: [PATCH] Add torrent downloading to user selected location add settings page --- HelperFunctions/index.ts | 114 +++++ HelperFunctions/index.tsx | 47 -- android/app/build.gradle | 5 +- android/app/src/main/AndroidManifest.xml | 12 +- .../main/java/com/yorha/FilePathModule.java | 441 ++++++++++++++++++ .../src/main/java/com/yorha/MainActivity.java | 21 +- .../main/java/com/yorha/MainApplication.java | 13 +- .../main/java/com/yorha/TorrentModule.java | 28 -- ...{TorrentPackage.java => YorhaPackage.java} | 4 +- android/build.gradle | 4 +- components/BottomNavBar.tsx | 11 +- components/DownloadTorrentButton.tsx | 119 +++++ components/ExportImportSettings.tsx | 40 ++ components/ReleaseShow.tsx | 82 ++-- components/SettingsTab.tsx | 45 ++ nodejs-assets/nodejs-project/main.js | 36 +- package-lock.json | 27 ++ package.json | 5 +- 18 files changed, 889 insertions(+), 165 deletions(-) create mode 100644 HelperFunctions/index.ts delete mode 100644 HelperFunctions/index.tsx create mode 100644 android/app/src/main/java/com/yorha/FilePathModule.java delete mode 100644 android/app/src/main/java/com/yorha/TorrentModule.java rename android/app/src/main/java/com/yorha/{TorrentPackage.java => YorhaPackage.java} (86%) create mode 100644 components/DownloadTorrentButton.tsx create mode 100644 components/ExportImportSettings.tsx create mode 100644 components/SettingsTab.tsx diff --git a/HelperFunctions/index.ts b/HelperFunctions/index.ts new file mode 100644 index 0000000..5a69b1f --- /dev/null +++ b/HelperFunctions/index.ts @@ -0,0 +1,114 @@ +import {Platform, PermissionsAndroid} from 'react-native'; +import {NativeModules} from 'react-native'; + +export async function promiseEach( + promiseArray: Promise[], + thenCallback: (item: T) => any, +) { + for (const item of promiseArray) { + item.then(data => thenCallback(data)); + } +} + +export const weekday = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +export function getDayOfWeek(dateString: string) { + const date = new Date(dateString); + return weekday[date.getDay()]; +} + +export function humanFileSize(bytes: number, si = false, dp = 1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** dp; + + do { + bytes /= thresh; + ++u; + } while ( + Math.round(Math.abs(bytes) * r) / r >= thresh && + u < units.length - 1 + ); + + return bytes.toFixed(dp) + ' ' + units[u]; +} + +interface FilePathModuleInterface { + getFolderPathFromUri( + contentUri: string, + callback: (directoryPath: string) => void, + ): void; + verifyManageFilesPermission(callback: (granted: boolean) => void): void; +} + +const {FilePathModule} = NativeModules; +const FilePathModuleTyped = FilePathModule as FilePathModuleInterface; +export const getRealPathFromContentUri = (contentUri: string) => { + return new Promise((resolve, reject) => { + FilePathModuleTyped.getFolderPathFromUri(contentUri, directoryPath => { + if (directoryPath) { + resolve(directoryPath); + } else { + reject(`Content Uri ${contentUri} could not be mapped to directory`); + } + }); + }); +}; + +export async function requestStoragePermission() { + if (Platform.OS !== 'android') { + return true; + } + + const checkManageFilesPermission = () => { + return new Promise(resolve => { + FilePathModuleTyped.verifyManageFilesPermission(resolve); + }); + }; + + const pm0 = await checkManageFilesPermission(); + if (!pm0) { + return false; + } + + const pm1 = await PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, + ); + const pm2 = await PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + ); + + if (pm1 && pm2) { + return true; + } + + const userResponse = await PermissionsAndroid.requestMultiple([ + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + ]); + + if ( + userResponse['android.permission.READ_EXTERNAL_STORAGE'] === 'granted' && + userResponse['android.permission.WRITE_EXTERNAL_STORAGE'] === 'granted' + ) { + return true; + } else { + return false; + } +} diff --git a/HelperFunctions/index.tsx b/HelperFunctions/index.tsx deleted file mode 100644 index e43244c..0000000 --- a/HelperFunctions/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -export async function promiseEach( - promiseArray: Promise[], - thenCallback: (item: T) => any, -) { - for (const item of promiseArray) { - item.then(data => thenCallback(data)); - } -} - -export const weekday = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', -]; - -export function getDayOfWeek(dateString: string) { - const date = new Date(dateString); - return weekday[date.getDay()]; -} - -export function humanFileSize(bytes: number, si = false, dp = 1) { - const thresh = si ? 1000 : 1024; - - if (Math.abs(bytes) < thresh) { - return bytes + ' B'; - } - - const units = si - ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; - let u = -1; - const r = 10 ** dp; - - do { - bytes /= thresh; - ++u; - } while ( - Math.round(Math.abs(bytes) * r) / r >= thresh && - u < units.length - 1 - ); - - return bytes.toFixed(dp) + ' ' + units[u]; -} diff --git a/android/app/build.gradle b/android/app/build.gradle index 982de06..b96867a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -191,8 +191,9 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" // From node_modules - - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" + implementation "androidx.activity:activity:1.4.0" + implementation "androidx.fragment:fragment:1.4.1" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation project(':react-native-fs') implementation project(':react-native-svg') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0232b6c..8b6ba0d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,14 @@ + xmlns:tools="http://schemas.android.com/tools" + package="com.yorha"> + + + + android:launchMode="singleTop" + android:windowSoftInputMode="adjustResize" + android:exported="true"> diff --git a/android/app/src/main/java/com/yorha/FilePathModule.java b/android/app/src/main/java/com/yorha/FilePathModule.java new file mode 100644 index 0000000..c8dd9ea --- /dev/null +++ b/android/app/src/main/java/com/yorha/FilePathModule.java @@ -0,0 +1,441 @@ +package com.yorha; // replace com.your-app-name with your app’s name + +import static android.os.Build.VERSION.SDK_INT; + +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; + +import androidx.activity.result.ActivityResultLauncher; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +public class FilePathModule extends ReactContextBaseJavaModule { + FilePathModule(ReactApplicationContext context) { + super(context); + } + + private Uri contentUri = null; + public static ActivityResultLauncher manageFileLauncher = null; + public static Callback manageFileLauncherCallback = null; + + @Override + public String getName() { + return "FilePathModule"; + } + + @ReactMethod + public void verifyManageFilesPermission(Callback callBack) { + if (SDK_INT >= Build.VERSION_CODES.R) { + if (!Environment.isExternalStorageManager()) { + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + Uri uri = Uri.fromParts("package", "com.yorha", null); + intent.setData(uri); + + FilePathModule.manageFileLauncherCallback = callBack; + FilePathModule.manageFileLauncher.launch(intent); + // Callback is handled on the activity result callback in main activity. + } + else { + callBack.invoke(true); + } + } + } + + // Adapted from https://github.com/saparkhid/AndroidFileNamePicker + @ReactMethod + public void getFolderPathFromUri(final String uriString, Callback callBack) { + // check here to KITKAT or new version + final boolean isKitKat = SDK_INT >= Build.VERSION_CODES.KITKAT; + final Uri uri = DocumentsContract.buildDocumentUriUsingTree(Uri.parse(uriString), DocumentsContract.getTreeDocumentId(Uri.parse(uriString))); + + String selection = null; + String[] selectionArgs = null; + // DocumentProvider + if (isKitKat) { + // ExternalStorageProvider + + if (isExternalStorageDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + String fullPath = getPathFromExtSD(split); + if (fullPath != "") { + callBack.invoke(fullPath); + } else { + callBack.invoke(); + } + return; + } + + + // DownloadsProvider + + if (isDownloadsDocument(uri)) { + + if (SDK_INT >= Build.VERSION_CODES.M) { + final String id; + Cursor cursor = null; + try { + cursor = this.getReactApplicationContext().getContentResolver().query(uri, new String[]{MediaStore.MediaColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + String fileName = cursor.getString(0); + String path = Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName; + if (!TextUtils.isEmpty(path)) { + callBack.invoke(path); + return; + } + } + } + finally { + if (cursor != null) + cursor.close(); + } + id = DocumentsContract.getDocumentId(uri); + if (!TextUtils.isEmpty(id)) { + if (id.startsWith("raw:")) { + callBack.invoke(id.replaceFirst("raw:", "")); + return; + } + String[] contentUriPrefixesToTry = new String[]{ + "content://downloads/public_downloads", + "content://downloads/my_downloads" + }; + for (String contentUriPrefix : contentUriPrefixesToTry) { + try { + final Uri contentUri = ContentUris.withAppendedId(Uri.parse(contentUriPrefix), Long.valueOf(id)); + + + callBack.invoke(getDataColumn(this.getReactApplicationContext(), contentUri, null, null)); + return; + } catch (NumberFormatException e) { + //In Android 8 and Android P the id is not a number + callBack.invoke(uri.getPath().replaceFirst("^/document/raw:", "").replaceFirst("^raw:", "")); + return; + } + } + + + } + } + else { + final String id = DocumentsContract.getDocumentId(uri); + + if (id.startsWith("raw:")) { + callBack.invoke(id.replaceFirst("raw:", "")); + return; + } + try { + contentUri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); + } + catch (NumberFormatException e) { + e.printStackTrace(); + } + if (contentUri != null) { + callBack.invoke(getDataColumn(this.getReactApplicationContext(), contentUri, null, null)); + return; + } + } + } + + + // MediaProvider + if (isMediaDocument(uri)) { + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + final String type = split[0]; + + Uri contentUri = null; + + if ("image".equals(type)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if ("video".equals(type)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else if ("audio".equals(type)) { + contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } + selection = "_id=?"; + selectionArgs = new String[]{split[1]}; + + + callBack.invoke(getDataColumn(this.getReactApplicationContext(), contentUri, selection, + selectionArgs)); + return; + } + + if (isGoogleDriveUri(uri)) { + callBack.invoke(getDriveFilePath(uri)); + return; + } + + if (isWhatsAppFile(uri)){ + callBack.invoke(getFilePathForWhatsApp(uri)); + return; + } + + + if ("content".equalsIgnoreCase(uri.getScheme())) { + + if (isGooglePhotosUri(uri)) { + callBack.invoke(uri.getLastPathSegment()); + return; + } + if (isGoogleDriveUri(uri)) { + callBack.invoke(getDriveFilePath(uri)); + return; + } + if (SDK_INT >= Build.VERSION_CODES.Q) + { + // return getFilePathFromURI(context,uri); + callBack.invoke(copyFileToInternalStorage(uri,"userfiles")); + // return getRealPathFromURI(context,uri); + return; + } + else + { + callBack.invoke(getDataColumn(this.getReactApplicationContext(), uri, null, null)); + return; + } + + } + if ("file".equalsIgnoreCase(uri.getScheme())) { + callBack.invoke(uri.getPath()); + return; + } + } + else { + + if (isWhatsAppFile(uri)){ + callBack.invoke(getFilePathForWhatsApp(uri)); + return; + } + + if ("content".equalsIgnoreCase(uri.getScheme())) { + String[] projection = { + MediaStore.Images.Media.DATA + }; + Cursor cursor = null; + try { + cursor = this.getReactApplicationContext().getContentResolver() + .query(uri, projection, selection, selectionArgs, null); + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + if (cursor.moveToFirst()) { + callBack.invoke(cursor.getString(column_index)); + return; + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } + callBack.invoke(); + } + + private boolean fileExists(String filePath) { + File file = new File(filePath); + + return file.exists(); + } + + private String getPathFromExtSD(String[] pathData) { + final String type = pathData[0]; + final String relativePath = "/" + pathData[1]; + String fullPath = ""; + + // on my Sony devices (4.4.4 & 5.1.1), `type` is a dynamic string + // something like "71F8-2C0A", some kind of unique id per storage + // don't know any API that can get the root path of that storage based on its id. + // + // so no "primary" type, but let the check here for other devices + if ("primary".equalsIgnoreCase(type)) { + fullPath = Environment.getExternalStorageDirectory() + relativePath; + if (fileExists(fullPath)) { + return fullPath; + } + } + + // Environment.isExternalStorageRemovable() is `true` for external and internal storage + // so we cannot relay on it. + // + // instead, for each possible path, check if file exists + // we'll start with secondary storage as this could be our (physically) removable sd card + fullPath = System.getenv("SECONDARY_STORAGE") + relativePath; + if (fileExists(fullPath)) { + return fullPath; + } + + fullPath = System.getenv("EXTERNAL_STORAGE") + relativePath; + if (fileExists(fullPath)) { + return fullPath; + } + + return fullPath; + } + + private String getDriveFilePath(Uri uri) { + Uri returnUri = uri; + Cursor returnCursor = this.getReactApplicationContext().getContentResolver().query(returnUri, null, null, null, null); + /* + * Get the column indexes of the data in the Cursor, + * * move to the first row in the Cursor, get the data, + * * and display it. + * */ + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE); + returnCursor.moveToFirst(); + String name = (returnCursor.getString(nameIndex)); + String size = (Long.toString(returnCursor.getLong(sizeIndex))); + File file = new File(this.getReactApplicationContext().getCacheDir(), name); + try { + InputStream inputStream = this.getReactApplicationContext().getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(file); + int read = 0; + int maxBufferSize = 1 * 1024 * 1024; + int bytesAvailable = inputStream.available(); + + //int bufferSize = 1024; + int bufferSize = Math.min(bytesAvailable, maxBufferSize); + + final byte[] buffers = new byte[bufferSize]; + while ((read = inputStream.read(buffers)) != -1) { + outputStream.write(buffers, 0, read); + } + Log.e("File Size", "Size " + file.length()); + inputStream.close(); + outputStream.close(); + Log.e("File Path", "Path " + file.getPath()); + Log.e("File Size", "Size " + file.length()); + } catch (Exception e) { + Log.e("Exception", e.getMessage()); + } + return file.getPath(); + } + + /*** + * Used for Android Q+ + * @param uri + * @param newDirName if you want to create a directory, you can set this variable + * @return + */ + private String copyFileToInternalStorage(Uri uri,String newDirName) { + Uri returnUri = uri; + + Cursor returnCursor = this.getReactApplicationContext().getContentResolver().query(returnUri, new String[]{ + OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE + }, null, null, null); + + + /* + * Get the column indexes of the data in the Cursor, + * * move to the first row in the Cursor, get the data, + * * and display it. + * */ + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE); + returnCursor.moveToFirst(); + String name = (returnCursor.getString(nameIndex)); + String size = (Long.toString(returnCursor.getLong(sizeIndex))); + + File output; + if(!newDirName.equals("")) { + File dir = new File(this.getReactApplicationContext().getFilesDir() + "/" + newDirName); + if (!dir.exists()) { + dir.mkdir(); + } + output = new File(this.getReactApplicationContext().getFilesDir() + "/" + newDirName + "/" + name); + } + else{ + output = new File(this.getReactApplicationContext().getFilesDir() + "/" + name); + } + try { + InputStream inputStream = this.getReactApplicationContext().getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(output); + int read = 0; + int bufferSize = 1024; + final byte[] buffers = new byte[bufferSize]; + while ((read = inputStream.read(buffers)) != -1) { + outputStream.write(buffers, 0, read); + } + + inputStream.close(); + outputStream.close(); + + } + catch (Exception e) { + + Log.e("Exception", e.getMessage()); + } + + return output.getPath(); + } + + private String getFilePathForWhatsApp(Uri uri){ + return copyFileToInternalStorage(uri,"whatsapp"); + } + + private String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) { + Cursor cursor = null; + final String column = "_data"; + final String[] projection = {column}; + + try { + cursor = context.getContentResolver().query(uri, projection, + selection, selectionArgs, null); + + if (cursor != null && cursor.moveToFirst()) { + final int index = cursor.getColumnIndexOrThrow(column); + return cursor.getString(index); + } + } + finally { + if (cursor != null) + cursor.close(); + } + + return null; + } + + private boolean isExternalStorageDocument(Uri uri) { + return "com.android.externalstorage.documents".equals(uri.getAuthority()); + } + + private boolean isDownloadsDocument(Uri uri) { + return "com.android.providers.downloads.documents".equals(uri.getAuthority()); + } + + private boolean isMediaDocument(Uri uri) { + return "com.android.providers.media.documents".equals(uri.getAuthority()); + } + + private boolean isGooglePhotosUri(Uri uri) { + return "com.google.android.apps.photos.content".equals(uri.getAuthority()); + } + + public boolean isWhatsAppFile(Uri uri){ + return "com.whatsapp.provider.media".equals(uri.getAuthority()); + } + + private boolean isGoogleDriveUri(Uri uri) { + return "com.google.android.apps.docs.storage".equals(uri.getAuthority()) || "com.google.android.apps.docs.storage.legacy".equals(uri.getAuthority()); + } +} + diff --git a/android/app/src/main/java/com/yorha/MainActivity.java b/android/app/src/main/java/com/yorha/MainActivity.java index ae909f9..3a3b313 100644 --- a/android/app/src/main/java/com/yorha/MainActivity.java +++ b/android/app/src/main/java/com/yorha/MainActivity.java @@ -1,8 +1,17 @@ package com.yorha; -import com.facebook.react.ReactActivity; +import static android.os.Build.VERSION.SDK_INT; + import android.content.Intent; import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; + +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; + +import com.facebook.react.ReactActivity; public class MainActivity extends ReactActivity { @@ -23,4 +32,14 @@ public void onConfigurationChanged(Configuration newConfig) { intent.putExtra("newConfig", newConfig); sendBroadcast(intent); } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (SDK_INT >= Build.VERSION_CODES.R) { + FilePathModule.manageFileLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> FilePathModule.manageFileLauncherCallback.invoke(Environment.isExternalStorageManager())); + } + } } diff --git a/android/app/src/main/java/com/yorha/MainApplication.java b/android/app/src/main/java/com/yorha/MainApplication.java index 4b3a681..96b2cdf 100644 --- a/android/app/src/main/java/com/yorha/MainApplication.java +++ b/android/app/src/main/java/com/yorha/MainApplication.java @@ -2,20 +2,19 @@ import android.app.Application; import android.content.Context; + import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; -import com.facebook.react.shell.MainReactPackage; -import com.oblador.vectoricons.VectorIconsPackage; import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.JSIModulePackage; import com.facebook.soloader.SoLoader; +import com.horcrux.svg.SvgPackage; +import com.swmansion.reanimated.ReanimatedJSIModulePackage; + import java.lang.reflect.InvocationTargetException; -import java.util.Arrays; import java.util.List; -import com.facebook.react.bridge.JSIModulePackage; -import com.swmansion.reanimated.ReanimatedJSIModulePackage; -import com.horcrux.svg.SvgPackage; public class MainApplication extends Application implements ReactApplication { @@ -30,7 +29,7 @@ public boolean getUseDeveloperSupport() { protected List getPackages() { List packages = new PackageList(this).getPackages(); packages.add(new SvgPackage()); - packages.add(new TorrentPackage()); + packages.add(new YorhaPackage()); return packages; } diff --git a/android/app/src/main/java/com/yorha/TorrentModule.java b/android/app/src/main/java/com/yorha/TorrentModule.java deleted file mode 100644 index 42537df..0000000 --- a/android/app/src/main/java/com/yorha/TorrentModule.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.yorha; // replace com.your-app-name with your app’s name -import android.util.Log; -import android.widget.Toast; - -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import java.util.Map; -import java.util.HashMap; - -public class TorrentModule extends ReactContextBaseJavaModule { - TorrentModule(ReactApplicationContext context) { - super(context); - } - - @Override - public String getName() { - return "TorrentDownloader"; - } - - @ReactMethod - public void createCalendarEvent(String name, String location) { - Toast.makeText(this.getReactApplicationContext(),"Hello Javatpoint",Toast.LENGTH_SHORT).show(); - } -} - diff --git a/android/app/src/main/java/com/yorha/TorrentPackage.java b/android/app/src/main/java/com/yorha/YorhaPackage.java similarity index 86% rename from android/app/src/main/java/com/yorha/TorrentPackage.java rename to android/app/src/main/java/com/yorha/YorhaPackage.java index 5e9f000..7d8bff1 100644 --- a/android/app/src/main/java/com/yorha/TorrentPackage.java +++ b/android/app/src/main/java/com/yorha/YorhaPackage.java @@ -8,7 +8,7 @@ import java.util.Collections; import java.util.List; -public class TorrentPackage implements ReactPackage { +public class YorhaPackage implements ReactPackage { @Override public List createViewManagers(ReactApplicationContext reactContext) { @@ -20,7 +20,7 @@ public List createNativeModules( ReactApplicationContext reactContext) { List modules = new ArrayList<>(); - modules.add(new TorrentModule(reactContext)); + modules.add(new FilePathModule(reactContext)); return modules; } diff --git a/android/build.gradle b/android/build.gradle index 93232f5..75a7fb2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,8 +4,8 @@ buildscript { ext { buildToolsVersion = "29.0.3" minSdkVersion = 21 - compileSdkVersion = 29 - targetSdkVersion = 29 + compileSdkVersion = 31 + targetSdkVersion = 31 ndkVersion = "20.1.5948944" } repositories { diff --git a/components/BottomNavBar.tsx b/components/BottomNavBar.tsx index e753856..e8ea488 100644 --- a/components/BottomNavBar.tsx +++ b/components/BottomNavBar.tsx @@ -9,6 +9,7 @@ import {ReleasesTab} from './ReleasesTab'; import {WatchListTab} from './WatchListTab'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import {ShowInfo} from '../models/models'; +import {SettingsTab} from './SettingsTab'; const Tab = createMaterialBottomTabNavigator(); export const BottomNavBar = () => { @@ -29,7 +30,7 @@ export const BottomNavBar = () => { ); const WatchListRoute = () => ; - const RecentsRoute = () => Recents; + const SettingsRoute = () => ; const refreshShowData = React.useCallback(async () => { setRefreshingReleasesList(true); @@ -114,12 +115,12 @@ export const BottomNavBar = () => { }} /> ( - + ), tabBarColor: colors.tertiary, }} diff --git a/components/DownloadTorrentButton.tsx b/components/DownloadTorrentButton.tsx new file mode 100644 index 0000000..5d68e21 --- /dev/null +++ b/components/DownloadTorrentButton.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import {Linking} from 'react-native'; +import {Button} from 'react-native-paper'; +import {ShowDownloadInfo} from '../models/models'; +import nodejs from 'nodejs-mobile-react-native'; +import {pickDirectory} from 'react-native-document-picker'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + getRealPathFromContentUri, + requestStoragePermission, +} from '../HelperFunctions'; +import {NativeModules} from 'react-native'; + +const {FilePathModule} = NativeModules; + +type DownloadTorrentButtonProps = { + resolution: string; + availableDownloads: ShowDownloadInfo[]; + showName: string; + callbackId: string; + onDownloadStatusChange: (newStatus: DownloadingStatus) => void; + onFileSizeObtained?: (fileSize: number) => void; + onDownloadProgress?: (percentage: number) => void; // 0 -> 1 + onDownloaded?: (totalDownloaded: number) => void; + onDownloadSpeed: (currentDownloadSpeed: number) => void; + onUploadSpeed: (currentUploadSpeed: number) => void; +}; + +export enum DownloadingStatus { + NotDownloading, + DownloadStarting, + Downloading, + Seeding, + Completed, +} + +export const DownloadTorrentButton = ({ + resolution, + availableDownloads, + showName, + callbackId, + onDownloadStatusChange, + onFileSizeObtained, + onDownloadProgress, + onDownloaded, + onDownloadSpeed, + onUploadSpeed, +}: DownloadTorrentButtonProps) => { + const showDownloadPathKey = `${showName}-download-path`; + + const desiredResoltion = availableDownloads.find( + showDownload => showDownload.res === resolution, + ); + if (!desiredResoltion) { + console.error( + 'Could not find specified resoultion for show', + JSON.stringify(availableDownloads), + 'Requested resolution', + resolution, + ); + return <>; + } + const openTorrent = () => { + // check if we can download it in the app here? + Linking.openURL(desiredResoltion.magnet); + }; + + const downloadTorrent = async () => { + // get stored location else + let path: string | null | undefined = 'a'; + if (!(await requestStoragePermission())) { + console.warn('Required permissions were not accepted.'); + } + path = await AsyncStorage.getItem(showDownloadPathKey); + if (!path) { + const fileLocation = await pickDirectory(); + if (!fileLocation) { + console.log('No file location selected'); + return; + } else { + path = await getRealPathFromContentUri(fileLocation.uri); + await AsyncStorage.setItem(showDownloadPathKey, path); + } + } + + onDownloadStatusChange(DownloadingStatus.DownloadStarting); + + nodejs.channel.addListener('message', async msg => { + if (msg.callbackId === callbackId) { + if (msg.name === 'torrent-metadata') { + onDownloadStatusChange(DownloadingStatus.Downloading); + onFileSizeObtained?.(msg.size); + } else if (msg.name === 'torrent-progress') { + onDownloadProgress?.(msg.progress); + onDownloaded?.(msg.downloaded); + onDownloadSpeed(msg.downloadSpeed); + onUploadSpeed(msg.uploadSpeed); + } else if (msg.name === 'torrent-done') { + onDownloadStatusChange(DownloadingStatus.Seeding); + } + } + }); + nodejs.channel.send({ + name: 'download-torrent', + callbackId, + magnetUri: desiredResoltion.magnet, + location: path, + }); + }; + + return ( + + ); +}; diff --git a/components/ExportImportSettings.tsx b/components/ExportImportSettings.tsx new file mode 100644 index 0000000..53210c2 --- /dev/null +++ b/components/ExportImportSettings.tsx @@ -0,0 +1,40 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import uniqBy from 'lodash.uniqby'; +import * as React from 'react'; +import { + BottomNavigation, + Text, + Title, + TouchableRipple, + useTheme, +} from 'react-native-paper'; +import {createMaterialBottomTabNavigator} from '@react-navigation/material-bottom-tabs'; +import {promiseEach} from '../HelperFunctions'; +import {SubsPleaseApi} from '../SubsPleaseApi'; +import {ReleasesTab} from './ReleasesTab'; +import {WatchListTab} from './WatchListTab'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {ShowInfo} from '../models/models'; +import { + FlatList, + ScrollView, + SectionList, + StatusBar, + StyleSheet, + View, +} from 'react-native'; +import {Appbar} from 'react-native-paper'; + +type ImportExportListItemProps = { + type: 'Import' | 'Export'; +}; + +export const ImportExportListItem = ({type}: ImportExportListItemProps) => { + return ( + console.log('hello')}> + + {type} data + + + ); +}; diff --git a/components/ReleaseShow.tsx b/components/ReleaseShow.tsx index b38fe8b..cbb25ab 100644 --- a/components/ReleaseShow.tsx +++ b/components/ReleaseShow.tsx @@ -31,14 +31,10 @@ const {TorrentDownloader} = NativeModules; import nodejs from 'nodejs-mobile-react-native'; import {DownloadDirectoryPath} from 'react-native-fs'; import * as Progress from 'react-native-progress'; - -enum DownloadingStatus { - NotDownloading, - DownloadStarting, - Downloading, - Seeding, - Completed, -} +import { + DownloadingStatus, + DownloadTorrentButton, +} from './DownloadTorrentButton'; type releaseShowProps = { showInfo: ShowInfo; @@ -117,50 +113,6 @@ export const ReleaseShow = ({ }, }); - const getMagnetButton = (resolution: ShowResolution) => { - const desiredResoltion = showInfo.downloads.find( - showDownload => showDownload.res === resolution, - ); - if (desiredResoltion) { - const openTorrent = () => { - // check if we can download it in the app here? - Linking.openURL(desiredResoltion.magnet); - }; - const downloadTorrent = () => { - setDownloadingStatus(DownloadingStatus.DownloadStarting); - nodejs.channel.addListener('message', msg => { - if (msg.callbackId === callbackId) { - if (msg.name === 'torrent-metadata') { - setDownloadingStatus(DownloadingStatus.Downloading); - setFileSize(msg.size); - } else if (msg.name === 'torrent-progress') { - setDownloadProgress(msg.progress); - setDownloaded(msg.downloaded); - setDownloadSpeed(msg.downloadSpeed); - setUploadSpeed(msg.uploadSpeed); - } else if (msg.name === 'torrent-done') { - setDownloadingStatus(DownloadingStatus.Seeding); - } - } - }); - nodejs.channel.send({ - name: 'download-torrent', - callbackId, - magnetUri: desiredResoltion.magnet, - location: DownloadDirectoryPath, - }); - }; - return ( - - ); - } - }; - const cardStyle = { marginLeft: 5, marginTop: 10, @@ -261,8 +213,30 @@ export const ReleaseShow = ({ if (downloadingStatus === DownloadingStatus.NotDownloading) { return ( - {getMagnetButton('720')} - {getMagnetButton('1080')} + setDownloadingStatus(status)} + onDownloadSpeed={newDownloadSpeed => + setDownloadSpeed(newDownloadSpeed) + } + onDownloadProgress={newProgress => setDownloadProgress(newProgress)} + onUploadSpeed={newUploadSpeed => setUploadSpeed(newUploadSpeed)} + /> + setDownloadingStatus(status)} + onDownloadSpeed={newDownloadSpeed => + setDownloadSpeed(newDownloadSpeed) + } + onDownloadProgress={newProgress => setDownloadProgress(newProgress)} + onUploadSpeed={newUploadSpeed => setUploadSpeed(newUploadSpeed)} + /> ); } diff --git a/components/SettingsTab.tsx b/components/SettingsTab.tsx new file mode 100644 index 0000000..90ead19 --- /dev/null +++ b/components/SettingsTab.tsx @@ -0,0 +1,45 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import uniqBy from 'lodash.uniqby'; +import * as React from 'react'; +import { + BottomNavigation, + Text, + Title, + TouchableRipple, + useTheme, +} from 'react-native-paper'; +import {createMaterialBottomTabNavigator} from '@react-navigation/material-bottom-tabs'; +import {promiseEach} from '../HelperFunctions'; +import {SubsPleaseApi} from '../SubsPleaseApi'; +import {ReleasesTab} from './ReleasesTab'; +import {WatchListTab} from './WatchListTab'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {ShowInfo} from '../models/models'; +import { + FlatList, + ScrollView, + SectionList, + StatusBar, + StyleSheet, + View, +} from 'react-native'; +import {Appbar} from 'react-native-paper'; +import {ImportExportListItem} from './ExportImportSettings'; + +export const SettingsTab = () => { + return ( + <> + + + + + + AsyncStorage.clear()}> + + Clear all data + + + + + ); +}; diff --git a/nodejs-assets/nodejs-project/main.js b/nodejs-assets/nodejs-project/main.js index e7b946f..570cfc1 100644 --- a/nodejs-assets/nodejs-project/main.js +++ b/nodejs-assets/nodejs-project/main.js @@ -32,6 +32,12 @@ class TorrentClient { }); this.torrent = torrent; console.log('Torrent client is downloading:', torrent.infoHash); + console.log( + 'Torrent data, file path:', + torrent.files[0].path, + 'file name:', + torrent.files[0].name, + ); const throttledDownloadHandler = throttle(() => { console.log('Torrent update', torrent.progress); rn_bridge.channel.send({ @@ -51,6 +57,8 @@ class TorrentClient { rn_bridge.channel.send({ name: 'torrent-done', callbackId: this.callbackId, + sourceFilePath: torrent.files[0].path, + sourceFileName: torrent.files[0].name, }); }); }); @@ -75,19 +83,23 @@ const torrentObjects = {}; // Echo every message received from react-native. rn_bridge.channel.on('message', msg => { - if (msg.name === 'download-torrent') { - const torrentClient = new TorrentClient(msg.callbackId); - torrentClient.downloadTorrent(msg.magnetUri, msg.location); - torrentObjects[msg.callbackId] = torrentClient; - } else if (msg.name === 'pause') { - if (torrentObjects[msg.callbackId]) { - console.log('Pasuing', msg.callbackId); - torrentObjects[msg.callbackId].pause(); - } - } else if (msg.name === 'resume') { - if (torrentObjects[msg.callbackId]) { - torrentObjects[msg.callbackId].resume(); + try { + if (msg.name === 'download-torrent') { + const torrentClient = new TorrentClient(msg.callbackId); + torrentClient.downloadTorrent(msg.magnetUri, msg.location); + torrentObjects[msg.callbackId] = torrentClient; + } else if (msg.name === 'pause') { + if (torrentObjects[msg.callbackId]) { + console.log('Pasuing', msg.callbackId); + torrentObjects[msg.callbackId].pause(); + } + } else if (msg.name === 'resume') { + if (torrentObjects[msg.callbackId]) { + torrentObjects[msg.callbackId].resume(); + } } + } catch (ex) { + console.log('ERROR in node process:', JSON.stringify(ex)); } }); diff --git a/package-lock.json b/package-lock.json index 35b268d..7fea767 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react-dom": "^17.0.2", "react-native": "^0.68.1", "react-native-appearance": "^0.3.4", + "react-native-document-picker": "^8.1.0", "react-native-fs": "^2.19.0", "react-native-gesture-handler": "^1.10.3", "react-native-paper": "^4.9.1", @@ -12297,6 +12298,24 @@ "nullthrows": "^1.1.1" } }, + "node_modules/react-native-document-picker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.1.0.tgz", + "integrity": "sha512-FdaehvEoqkVkMTkIy09wpgHUHh9SskI1k8ug8Dwkwk7MJ+XxzrphAk/mXZtu5RkM1Iwxmd82QfwiQJxrZ2LSVg==", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, "node_modules/react-native-fs": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.19.0.tgz", @@ -24803,6 +24822,14 @@ "nullthrows": "^1.1.1" } }, + "react-native-document-picker": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.1.0.tgz", + "integrity": "sha512-FdaehvEoqkVkMTkIy09wpgHUHh9SskI1k8ug8Dwkwk7MJ+XxzrphAk/mXZtu5RkM1Iwxmd82QfwiQJxrZ2LSVg==", + "requires": { + "invariant": "^2.2.4" + } + }, "react-native-fs": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.19.0.tgz", diff --git a/package.json b/package.json index d12f956..2efedae 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,11 @@ "private": true, "scripts": { "android": "react-native run-android", - "android-prod": "react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res/ && cd android && gradlew assembleRelease", "ios": "react-native run-ios", "start": "react-native start", "test": "jest", - "lint": "eslint . --ext .js,.jsx,.ts,.tsx" + "lint": "eslint . --ext .js,.jsx,.ts,.tsx", + "log": "adb logcat" }, "dependencies": { "@react-native-async-storage/async-storage": "^1.15.5", @@ -24,6 +24,7 @@ "react-dom": "^17.0.2", "react-native": "^0.68.1", "react-native-appearance": "^0.3.4", + "react-native-document-picker": "^8.1.0", "react-native-fs": "^2.19.0", "react-native-gesture-handler": "^1.10.3", "react-native-paper": "^4.9.1",