mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-04 13:26:00 -04:00
Merge pull request #2822 from spacedriveapp/mob-109-fix-native-api-not-opening-files
This commit is contained in:
@@ -64,7 +64,8 @@
|
||||
}
|
||||
],
|
||||
["./scripts/withRiveAssets.js"],
|
||||
["./scripts/withAndroidIntent.js"]
|
||||
["./scripts/withAndroidIntent.js"],
|
||||
["./scripts/withNativeFunctions.js"]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
24
apps/mobile/modules/native-functions/NativeFunctions.m
Normal file
24
apps/mobile/modules/native-functions/NativeFunctions.m
Normal 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
|
||||
|
||||
192
apps/mobile/modules/native-functions/NativeFunctions.swift
Normal file
192
apps/mobile/modules/native-functions/NativeFunctions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
84
apps/mobile/scripts/withNativeFunctions.js
Normal file
84
apps/mobile/scripts/withNativeFunctions.js
Normal 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;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
Reference in New Issue
Block a user