Merge pull request #2822 from spacedriveapp/mob-109-fix-native-api-not-opening-files

This commit is contained in:
Ameer Al Ashhab
2024-11-28 12:10:17 +03:00
committed by GitHub
6 changed files with 378 additions and 25 deletions

View File

@@ -64,7 +64,8 @@
}
],
["./scripts/withRiveAssets.js"],
["./scripts/withAndroidIntent.js"]
["./scripts/withAndroidIntent.js"],
["./scripts/withNativeFunctions.js"]
]
}
}

View File

@@ -0,0 +1,24 @@
//
// NativeFunctions.m
// Spacedrive
//
// Created by Arnab Chakraborty on November 27, 2024.
//
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(NativeFunctions, NSObject)
RCT_EXTERN_METHOD(saveLocation:(nonnull NSString *)path
locationId:(nonnull NSNumber *)locationId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(previewFile:(nonnull NSString *)path
locationId:(nonnull NSNumber *)locationId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end

View File

@@ -0,0 +1,192 @@
//
// NativeFunctions.swift
// Spacedrive
//
// Created by Arnab Chakraborty on November 27, 2024.
//
import Foundation
import UIKit
import QuickLook
@objc(NativeFunctions)
class NativeFunctions: NSObject, QLPreviewControllerDataSource {
private var fileURL: URL?
@objc
static func requiresMainQueueSetup() -> Bool {
return true
}
private func getBookmarkStoragePath(for id: Int) -> URL {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return documentsDirectory.appendingPathComponent("\(id).sd_bookmark")
}
@objc
func saveLocation(_ path: String,
locationId: NSNumber,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
do {
let url = URL(fileURLWithPath: path)
guard url.startAccessingSecurityScopedResource() else {
reject("ERROR", "Cannot access directory", nil)
return
}
defer { url.stopAccessingSecurityScopedResource() }
let bookmarkData = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
let bookmarkPath = getBookmarkStoragePath(for: locationId.intValue)
try bookmarkData.write(to: bookmarkPath, options: .atomicWrite)
resolve(["success": true])
} catch {
reject("ERROR", "Failed to create bookmark: \(error.localizedDescription)", nil)
}
}
@objc
func previewFile(_ path: String,
locationId: NSNumber,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
#if DEBUG
print("🔍 PreviewFile called with path: \(path), locationId: \(locationId)")
#endif
do {
let bookmarkPath = getBookmarkStoragePath(for: locationId.intValue)
#if DEBUG
print("📁 Bookmark path: \(bookmarkPath)")
#endif
let fileURL = URL(fileURLWithPath: path)
#if DEBUG
print("📄 File URL: \(fileURL)")
#endif
if FileManager.default.fileExists(atPath: bookmarkPath.path) {
#if DEBUG
print("✅ Bookmark exists at path")
#endif
let bookmarkData = try Data(contentsOf: bookmarkPath)
#if DEBUG
print("📊 Bookmark data size: \(bookmarkData.count) bytes")
#endif
var isStale = false
let directoryURL = try URL(
resolvingBookmarkData: bookmarkData,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
#if DEBUG
print("📂 Resolved directory URL: \(directoryURL)")
print("🔄 Is bookmark stale? \(isStale)")
#endif
guard directoryURL.startAccessingSecurityScopedResource() else {
#if DEBUG
print("❌ Failed to access security-scoped resource for directory")
#endif
reject("ERROR", "Cannot access directory", nil)
return
}
defer {
directoryURL.stopAccessingSecurityScopedResource()
#if DEBUG
print("🔒 Stopped accessing security-scoped resource")
#endif
}
let fileName = fileURL.lastPathComponent
#if DEBUG
print("📝 File name: \(fileName)")
#endif
let resolvedFileURL = directoryURL.appendingPathComponent(fileName)
#if DEBUG
print("🎯 Resolved file URL: \(resolvedFileURL)")
#endif
// Check if file exists at resolved path
if FileManager.default.fileExists(atPath: resolvedFileURL.path) {
#if DEBUG
print("✅ File exists at resolved path")
#endif
} else {
#if DEBUG
print("⚠️ File does not exist at resolved path")
#endif
}
self.fileURL = resolvedFileURL
#if DEBUG
print("💾 Set fileURL for QuickLook: \(resolvedFileURL)")
#endif
} else {
#if DEBUG
print("❌ Bookmark not found at path: \(bookmarkPath)")
#endif
reject("ERROR", "Bookmark not found for this location", nil)
return
}
#if DEBUG
print("🚀 Preparing to present QuickLook controller")
#endif
DispatchQueue.main.async {
let previewController = QLPreviewController()
previewController.dataSource = self
guard let presentedVC = RCTPresentedViewController() else {
#if DEBUG
print("❌ Failed to get presented view controller")
#endif
reject("ERROR", "Cannot present preview", nil)
return
}
#if DEBUG
print("📱 Presenting QuickLook controller")
#endif
presentedVC.present(previewController, animated: true) {
#if DEBUG
print("✨ QuickLook controller presented successfully")
#endif
resolve(["success": true])
}
}
} catch {
#if DEBUG
print("💥 Error occurred: \(error.localizedDescription)")
print("🔍 Detailed error: \(error)")
#endif
reject("ERROR", "Failed to preview file: \(error.localizedDescription)", nil)
}
}
// MARK: - QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
#if DEBUG
print("📊 numberOfPreviewItems called, returning: \(fileURL != nil ? 1 : 0)")
#endif
return fileURL != nil ? 1 : 0
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
#if DEBUG
print("🎯 previewItemAt called for index: \(index)")
print("📄 Returning fileURL: \(String(describing: fileURL))")
#endif
return fileURL! as QLPreviewItem
}
}

View File

@@ -0,0 +1,84 @@
const { withXcodeProject } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* @typedef {Object} XcodeProject
* @property {Function} pbxGroupByName - Gets a PBX group by name
* @property {Function} findPBXGroupKey - Finds PBX group key
* @property {(filePath: string, target?: string | null, groupKey: string) => string} addSourceFile - Adds source file to project and returns the file reference
*/
/**
* @typedef {Object} ExpoConfig
* @property {XcodeProject} modResults - Xcode project modification results
*/
/**
* Adds native Swift functions to iOS Xcode project
* @param {import('@expo/config-plugins').ExpoConfig} config - Expo config object
* @returns {Promise<import('@expo/config-plugins').ExpoConfig>} Modified config
*/
/**
* Enhances the provided configuration with native functions for an iOS project.
*
* This function modifies the Xcode project by copying necessary `.swift` and `.m` files
* to the iOS project directory and adding them to the project. It also updates the
* `Spacedrive-Bridging-Header.h` file with the required imports.
*
* @param {object} config - The configuration object to enhance.
* @returns {object} The modified configuration object.
*
* @modifies Spacedrive-Bridging-Header.h
* // This file is autogenerated by `withNativeFunctions.js`. Do not modify this file
* #import <React/RCTBridge.h>
*/
const withNativeFunctions = (config) => {
const mod = withXcodeProject(config, async (config) => {
/** @type {XcodeProject} */
const project = config.modResults;
/** @type {{name: string, path: string}} */
const group = project.pbxGroupByName('Spacedrive');
/** @type {string} */
const key = project.findPBXGroupKey({ name: group.name, path: group.path });
const iosProjectFolder = path.join(__dirname, '../ios');
// Copy the .swift and .m files to the iOS project
fs.copyFileSync(
path.join(__dirname, '../modules/native-functions/NativeFunctions.swift'),
path.join(iosProjectFolder, 'NativeFunctions.swift')
);
fs.copyFileSync(
path.join(__dirname, '../modules/native-functions/NativeFunctions.m'),
path.join(iosProjectFolder, 'NativeFunctions.m')
);
// Add the .swift file to the project
config.modResults.addSourceFile('NativeFunctions.swift', null, key);
// Add the .m file to the project
config.modResults.addSourceFile('NativeFunctions.m', null, key);
// Update the Spacedrive-Bridging-Header.h file
const bridgingHeaderPath = path.join(
iosProjectFolder,
'/Spacedrive/Spacedrive-Bridging-Header.h'
);
// Empty the file first
fs.writeFileSync(bridgingHeaderPath, '');
const comment =
'// This file is autogenerated by `withNativeFunctions.js`. Do not modify this file, as it will be overwritten by the build process.\n';
const importStatement = '#import <React/RCTBridge.h>\n';
// Write new content
fs.writeFileSync(bridgingHeaderPath, comment + importStatement);
return config;
});
return mod;
};
module.exports = withNativeFunctions;

View File

@@ -1,9 +1,10 @@
import RNFS from '@dr.pogodin/react-native-fs';
import { useNavigation } from '@react-navigation/native';
import { FlashList } from '@shopify/flash-list';
import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query';
import * as Haptics from 'expo-haptics';
import { useRef } from 'react';
import { ActivityIndicator } from 'react-native';
import React, { useRef } from 'react';
import { ActivityIndicator, NativeModules, Platform } from 'react-native';
import FileViewer from 'react-native-file-viewer';
import {
getIndexedItemFilePath,
@@ -27,6 +28,8 @@ import FileMedia from './FileMedia';
import FileRow from './FileRow';
import Menu from './menu/Menu';
const { NativeFunctions } = NativeModules;
type ExplorerProps = {
tabHeight?: boolean;
items: ExplorerItem[] | null;
@@ -54,19 +57,37 @@ const Explorer = (props: Props) => {
//Open file with native api
async function handleOpen(data: ExplorerItem) {
try {
const filePath = getIndexedItemFilePath(data);
const filePath = getIndexedItemFilePath(data);
if (Platform.OS === 'android') {
try {
const absolutePath = await libraryClient.query([
'files.getPath',
filePath?.id ?? -1
]);
if (!absolutePath) return;
await FileViewer.open(absolutePath, {
// Android only
showAppsSuggestions: false, // If there is not an installed app that can open the file, open the Play Store with suggested apps
showOpenWithDialog: true // if there is more than one app that can open the file, show an Open With dialogue box
});
if (filePath && filePath.object_id)
await libraryClient.mutation(['files.updateAccessTime', [filePath.object_id]]);
} catch (error) {
console.error('Error opening object', error);
toast.error('Error opening object');
}
} else {
// iOS
const absolutePath = await libraryClient.query(['files.getPath', filePath?.id ?? -1]);
if (!absolutePath) return;
await FileViewer.open(absolutePath, {
// Android only
showAppsSuggestions: false, // If there is not an installed app that can open the file, open the Play Store with suggested apps
showOpenWithDialog: true // if there is more than one app that can open the file, show an Open With dialogue box
});
if (filePath && filePath.object_id)
await libraryClient.mutation(['files.updateAccessTime', [filePath.object_id]]);
} catch (error) {
toast.error('Error opening object');
if (!filePath?.location_id) return;
try {
// These arguments cannot be null due to compatability with Android (React Native throws an error if even the type is nullable)
await NativeFunctions.previewFile(absolutePath!, filePath!.location_id!);
} catch (error) {
console.error('Error previewing file:', error);
toast.error('Error previewing file');
}
}
}

View File

@@ -1,8 +1,8 @@
import * as RNFS from '@dr.pogodin/react-native-fs';
import { forwardRef, useCallback } from 'react';
import { Alert, Platform, Text, View } from 'react-native';
import { Alert, NativeModules, Platform, Text, View } from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import { useLibraryMutation, useRspcLibraryContext } from '@sd/client';
import { useLibraryMutation, useLibraryQuery, useRspcLibraryContext } from '@sd/client';
import { Modal, ModalRef } from '~/components/layout/Modal';
import { Button } from '~/components/primitive/Button';
import useForwardedRef from '~/hooks/useForwardedRef';
@@ -11,6 +11,18 @@ import { tw } from '~/lib/tailwind';
import { Icon } from '../icons/Icon';
import { toast } from '../primitive/Toast';
const { NativeFunctions } = NativeModules;
interface DirectoryPickerResult {
path: string;
bookmarkFile: string;
}
interface DirectoryPickerModule {
pickDirectory(): Promise<DirectoryPickerResult>;
resolveBookmark(bookmarkFileName: string): Promise<{ path: string }>;
}
// import * as ML from 'expo-media-library';
// WIP component
@@ -43,7 +55,18 @@ const ImportModal = forwardRef<ModalRef, unknown>((_, ref) => {
throw new Error('Unimplemented custom remote error handling');
}
},
onSuccess: () => {
onSuccess: async (data) => {
// Fetch the location's path using the location number
const location = await rspc.client.query(['locations.get', data!]);
const locationPath = location?.path;
try {
// These arguments cannot be null due to compatability with Android (React Native throws an error if even the type is nullable)
await NativeFunctions.saveLocation(locationPath!, data!);
} catch (error) {
console.error('Error saving location:', error);
toast.error('Error saving location bookmark');
return;
}
toast.success('Location added successfully');
},
onSettled: () => {
@@ -53,16 +76,24 @@ const ImportModal = forwardRef<ModalRef, unknown>((_, ref) => {
});
const handleFilesButton = useCallback(async () => {
const response = await DocumentPicker.pickDirectory({
presentationStyle: 'pageSheet'
});
if (!response) return;
const uri = response.uri;
try {
const response = await DocumentPicker.pickDirectory({
presentationStyle: 'pageSheet'
});
if (!response) return;
const uri = response.uri;
if (Platform.OS === 'android') {
const response = await DocumentPicker.pickDirectory({
presentationStyle: 'pageSheet'
});
if (!response) return;
const uri = response.uri;
// The following code turns this: content://com.android.externalstorage.documents/tree/[filePath] into this: /storage/emulated/0/[directoryName]
// Example: content://com.android.externalstorage.documents/tree/primary%3ADownload%2Ftest into /storage/emulated/0/Download/test
const dirName = decodeURIComponent(uri).split('/');