1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Mobile: Camera screen: Support scanning QR codes ()

This commit is contained in:
Henry Heino 2024-10-30 14:12:27 -07:00 committed by GitHub
parent 441021bb7e
commit 100f8a23f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2995 additions and 465 deletions

View File

@ -553,7 +553,16 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BackButtonDialogBox.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/DialogManager.js
packages/app-mobile/components/DismissibleDialog.js
@ -730,6 +739,7 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js

12
.gitignore vendored
View File

@ -530,7 +530,16 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BackButtonDialogBox.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/DialogManager.js
packages/app-mobile/components/DismissibleDialog.js
@ -707,6 +716,7 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js

View File

@ -84,9 +84,6 @@ android {
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}
// https://github.com/react-native-community/react-native-camera/issues/2138
missingDimensionStrategy 'react-native-camera', 'general'
// Needed to fix: The number of method references in a .dex file cannot exceed 64K
multiDexEnabled true
@ -122,12 +119,6 @@ android {
}
dependencies {
// This removes proprietary bits to enable inclusion in F-Droid
// https://gitlab.com/fdroid/rfp/-/issues/434#note_443458711
implementation (project(':react-native-camera')){
exclude group: 'com.google.android.gms', module: 'play-services-vision'
}
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")

View File

@ -1,4 +1,5 @@
package net.cozic.joplin
import expo.modules.ReactActivityDelegateWrapper
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
@ -18,5 +19,5 @@ class MainActivity : ReactActivity() {
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled))
}

View File

@ -1,4 +1,7 @@
package net.cozic.joplin
import android.content.res.Configuration
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
import android.app.Application
import android.database.CursorWindow
@ -18,7 +21,7 @@ import net.cozic.joplin.ssl.SslPackage
import net.cozic.joplin.textinput.TextInputPackage
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
@ -35,10 +38,10 @@ class MainApplication : Application(), ReactApplication {
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
})
override val reactHost: ReactHost
get() = getDefaultReactHost(this.applicationContext, reactNativeHost)
get() = ReactNativeHostWrapper.createReactHost(this.applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
@ -59,5 +62,11 @@ class MainApplication : Application(), ReactApplication {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
}
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

View File

@ -53,6 +53,11 @@ allprojects {
google()
maven { url 'https://www.jitpack.io' }
maven {
// expo-camera bundles a custom com.google.android:cameraview
url "$rootDir/../node_modules/expo-camera/android/maven"
}
}
}

View File

@ -3,4 +3,6 @@ include ':react-native-vector-icons'
project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')
includeBuild('../node_modules/@react-native/gradle-plugin')
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle")
useExpoModules()

View File

@ -1,247 +0,0 @@
const { RNCamera } = require('react-native-camera');
const React = require('react');
const Component = React.Component;
const { connect } = require('react-redux');
const { View, TouchableOpacity, Text, Dimensions } = require('react-native');
const Icon = require('react-native-vector-icons/Ionicons').default;
const { _ } = require('@joplin/lib/locale');
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
class CameraView extends Component {
public constructor() {
super();
const dimensions = Dimensions.get('window');
this.state = {
snapping: false,
ratios: [],
screenWidth: dimensions.width,
screenHeight: dimensions.height,
};
this.back_onPress = this.back_onPress.bind(this);
this.photo_onPress = this.photo_onPress.bind(this);
this.reverse_onPress = this.reverse_onPress.bind(this);
this.ratio_onPress = this.ratio_onPress.bind(this);
this.onCameraReady = this.onCameraReady.bind(this);
this.onLayout = this.onLayout.bind(this);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public onLayout(event: any) {
this.setState({
screenWidth: event.nativeEvent.layout.width,
screenHeight: event.nativeEvent.layout.height,
});
}
private back_onPress() {
if (this.props.onCancel) this.props.onCancel();
}
private reverse_onPress() {
if (this.props.cameraType === RNCamera.Constants.Type.back) {
Setting.setValue('camera.type', RNCamera.Constants.Type.front);
} else {
Setting.setValue('camera.type', RNCamera.Constants.Type.back);
}
}
private ratio_onPress() {
if (this.state.ratios.length <= 1) return;
let index = this.state.ratios.indexOf(this.props.cameraRatio);
index++;
if (index >= this.state.ratios.length) index = 0;
Setting.setValue('camera.ratio', this.state.ratios[index]);
}
private async photo_onPress() {
if (!this.camera || !this.props.onPhoto) return;
this.setState({ snapping: true });
const result = await this.camera.takePictureAsync({
quality: 0.8,
exif: true,
fixOrientation: true,
});
this.setState({ snapping: false });
if (this.props.onPhoto) this.props.onPhoto(result);
}
public async onCameraReady() {
if (this.supportsRatios()) {
const ratios = await this.camera.getSupportedRatiosAsync();
this.setState({ ratios: ratios });
}
}
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
public renderButton(onPress: Function, iconNameOrIcon: any, style: any) {
let icon = null;
if (typeof iconNameOrIcon === 'string') {
icon = (
<Icon
name={iconNameOrIcon}
style={{
fontSize: 40,
color: 'black',
}}
/>
);
} else {
icon = iconNameOrIcon;
}
return (
<TouchableOpacity onPress={onPress} style={{ ...style }}>
<View style={{ borderRadius: 32, width: 60, height: 60, borderColor: '#00000040', borderWidth: 1, borderStyle: 'solid', backgroundColor: '#ffffff77', justifyContent: 'center', alignItems: 'center', alignSelf: 'baseline' }}>
{ icon }
</View>
</TouchableOpacity>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public fitRectIntoBounds(rect: any, bounds: any) {
const rectRatio = rect.width / rect.height;
const boundsRatio = bounds.width / bounds.height;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const newDimensions: any = {};
// Rect is more landscape than bounds - fit to width
if (rectRatio > boundsRatio) {
newDimensions.width = bounds.width;
newDimensions.height = rect.height * (bounds.width / rect.width);
} else { // Rect is more portrait than bounds - fit to height
newDimensions.width = rect.width * (bounds.height / rect.height);
newDimensions.height = bounds.height;
}
return newDimensions;
}
public cameraRect(ratio: string) {
// To keep the calculations simpler, it's assumed that the phone is in
// portrait orientation. Then at the end we swap the values if needed.
const splitted = ratio.split(':');
const output = this.fitRectIntoBounds({
width: Number(splitted[1]),
height: Number(splitted[0]),
}, {
width: Math.min(this.state.screenWidth, this.state.screenHeight),
height: Math.max(this.state.screenWidth, this.state.screenHeight),
});
if (this.state.screenWidth > this.state.screenHeight) {
const w = output.width;
output.width = output.height;
output.height = w;
}
return output;
}
public supportsRatios() {
return shim.mobilePlatform() === 'android';
}
public render() {
const photoIcon = this.state.snapping ? 'checkmark' : 'camera';
const displayRatios = this.supportsRatios() && this.state.ratios.length > 1;
const reverseCameraButton = this.renderButton(this.reverse_onPress, 'camera-reverse', { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', marginLeft: 20 });
const ratioButton = !displayRatios ? <View style={{ flex: 1 }}/> : this.renderButton(this.ratio_onPress, <Text style={{ fontWeight: 'bold', fontSize: 20 }}>{Setting.value('camera.ratio')}</Text>, { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20 });
let cameraRatio = '4:3';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const cameraProps: any = {};
if (displayRatios) {
cameraProps.ratio = this.props.cameraRatio;
cameraRatio = this.props.cameraRatio;
}
const cameraRect = this.cameraRect(cameraRatio);
cameraRect.left = (this.state.screenWidth - cameraRect.width) / 2;
cameraRect.top = (this.state.screenHeight - cameraRect.height) / 2;
return (
<View style={{ ...this.props.style, position: 'relative' }} onLayout={this.onLayout}>
<View style={{ position: 'absolute', backgroundColor: '#000000', width: '100%', height: '100%' }}/>
<RNCamera
style={({ position: 'absolute', ...cameraRect })}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ref={(ref: any) => {
this.camera = ref;
}}
type={this.props.cameraType}
captureAudio={false}
onCameraReady={this.onCameraReady}
androidCameraPermissionOptions={{
title: _('Permission to use camera'),
message: _('Your permission to use your camera is required.'),
buttonPositive: _('OK'),
buttonNegative: _('Cancel'),
}}
{ ...cameraProps }
>
<View style={{ flex: 1, justifyContent: 'space-between', flexDirection: 'column' }}>
<View style={{ flex: 1, justifyContent: 'flex-start' }}>
<TouchableOpacity onPress={this.back_onPress}>
<View style={{ marginLeft: 5, marginTop: 5, borderColor: '#00000040', borderWidth: 1, borderStyle: 'solid', borderRadius: 90, width: 50, height: 50, display: 'flex', backgroundColor: '#ffffff77', justifyContent: 'center', alignItems: 'center' }}>
<Icon
name={'arrow-back'}
style={{
fontSize: 40,
color: 'black',
}}
/>
</View>
</TouchableOpacity>
</View>
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'flex-end' }}>
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginBottom: 20 }}>
{ reverseCameraButton }
<TouchableOpacity onPress={this.photo_onPress} disabled={this.state.snapping}>
<View style={{ flexDirection: 'row', borderRadius: 90, width: 90, height: 90, backgroundColor: '#ffffffaa', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Icon
name={photoIcon}
style={{
fontSize: 60,
color: 'black',
}}
/>
</View>
</TouchableOpacity>
{ ratioButton }
</View>
</View>
</View>
</RNCamera>
</View>
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const mapStateToProps = (state: any) => {
return {
cameraRatio: state.settings['camera.ratio'],
cameraType: state.settings['camera.type'],
};
};
export default connect(mapStateToProps)(CameraView);

View File

@ -0,0 +1,155 @@
import * as React from 'react';
import { useMemo } from 'react';
import { View, StyleSheet, ViewStyle, Platform } from 'react-native';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import { ActivityIndicator } from 'react-native-paper';
interface Props {
themeId: number;
onCameraReverse: ()=> void;
cameraDirection: CameraDirection;
onSetCameraRatio: ()=> void;
cameraRatio: string;
onCancelPhoto: ()=> void;
onTakePicture: ()=> void;
takingPicture: boolean;
cameraReady: boolean;
}
const useStyles = () => {
return useMemo(() => {
const buttonContainer: ViewStyle = {
borderRadius: 32,
minWidth: 60,
minHeight: 60,
borderColor: '#00000040',
borderWidth: 1,
borderStyle: 'solid',
backgroundColor: '#ffffff77',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'flex-end',
};
const buttonRowContainer: ViewStyle = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
left: 20,
right: 20,
position: 'absolute',
};
return StyleSheet.create({
buttonRowContainerTop: {
...buttonRowContainer,
top: 20,
},
buttonRowContainerBottom: {
...buttonRowContainer,
bottom: 20,
},
buttonContainer,
buttonContent: {
color: 'black',
fontSize: 40,
},
buttonPlaceHolder: {
width: 60,
},
qrCodeButtonDimmed: {
...buttonContainer,
backgroundColor: '#ffffff44',
borderWidth: 0,
},
takePhotoButtonContainer: {
...buttonContainer,
minWidth: 80,
minHeight: 80,
borderRadius: 80,
},
takePhotoButtonContent: {
color: 'black',
fontSize: 60,
},
ratioButtonContainer: {
...buttonContainer,
aspectRatio: undefined,
padding: 12,
},
ratioButtonContent: {
fontSize: 20,
color: 'black',
},
});
}, []);
};
const ActionButtons: React.FC<Props> = props => {
const styles = useStyles();
const reverseButton = (
<IconButton
iconName='ionicon camera-reverse'
onPress={props.onCameraReverse}
description={props.cameraDirection === CameraDirection.Front ? _('Switch to back-facing camera') : _('Switch to front-facing camera')}
themeId={props.themeId}
iconStyle={styles.buttonContent}
containerStyle={styles.buttonContainer}
/>
);
const takePhotoButton = (
<IconButton
iconName={props.takingPicture ? 'ionicon checkmark' : 'ionicon camera'}
onPress={props.onTakePicture}
description={props.takingPicture ? _('Processing photo...') : _('Take picture')}
themeId={props.themeId}
iconStyle={styles.takePhotoButtonContent}
containerStyle={styles.takePhotoButtonContainer}
/>
);
const ratioButton = (
<IconButton
themeId={props.themeId}
iconName={`text ${props.cameraRatio}`}
onPress={props.onSetCameraRatio}
iconStyle={styles.ratioButtonContent}
containerStyle={styles.ratioButtonContainer}
description={_('Change ratio')}
/>
);
const cameraActions = (
<View style={styles.buttonRowContainerBottom}>
{reverseButton}
{takePhotoButton}
{
// Changing ratio is only supported on Android:
Platform.OS === 'android' ? ratioButton : <View style={styles.buttonPlaceHolder}/>
}
</View>
);
return <>
<View style={styles.buttonRowContainerTop}>
<IconButton
themeId={props.themeId}
iconName='ionicon arrow-back'
containerStyle={styles.buttonContainer}
iconStyle={styles.buttonContent}
onPress={props.onCancelPhoto}
description={_('Back')}
/>
</View>
{props.cameraReady ? cameraActions : <ActivityIndicator/>}
</>;
};
export default ActionButtons;

View File

@ -0,0 +1,40 @@
import * as React from 'react';
import { ForwardedRef, forwardRef, useCallback, useImperativeHandle } from 'react';
import { CameraRef, Props } from './types';
import { PrimaryButton } from '../../buttons';
import { Surface, Text } from 'react-native-paper';
import shim from '@joplin/lib/shim';
import { TextInput } from 'react-native';
const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
useImperativeHandle(ref, () => ({
takePictureAsync: async () => {
const path = `${shim.fsDriver().getCacheDirectoryPath()}/test-photo.svg`;
await shim.fsDriver().writeFile(
path,
`<svg viewBox="0 0 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text>
</svg>`,
'utf8',
);
return { uri: path, type: 'image/svg+xml' };
},
}), []);
const onCodeChange = useCallback((data: string) => {
props.codeScanner.onBarcodeScanned?.({
data,
type: 'qr',
});
}, [props.codeScanner]);
return <Surface elevation={1}>
<Text>Camera mock</Text>
<PrimaryButton onPress={props.onPermissionRequestFailure}>Reject permission</PrimaryButton>
<PrimaryButton onPress={props.onHasPermission}>Accept permission</PrimaryButton>
<PrimaryButton onPress={props.onCameraReady}>On camera ready</PrimaryButton>
<TextInput placeholder='QR code data' onChangeText={onCodeChange}/>
</Surface>;
};
export default forwardRef(Camera);

View File

@ -0,0 +1,57 @@
import * as React from 'react';
import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import { BarcodeSettings, CameraRatio, CameraView, useCameraPermissions } from 'expo-camera';
import { ForwardedRef, forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { CameraRef, Props } from './types';
const barcodeScannerSettings: BarcodeSettings = {
// Rocketbook pages use both QR and datamatrix
barcodeTypes: ['qr', 'datamatrix'],
};
const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
const cameraRef = useRef<CameraView>(null);
useImperativeHandle(ref, () => ({
takePictureAsync: async () => {
const result = await cameraRef.current.takePictureAsync();
return {
uri: result.uri,
type: 'image/jpg',
};
},
}), []);
const [hasPermission, requestPermission] = useCameraPermissions();
useAsyncEffect(async () => {
try {
if (!hasPermission?.granted) {
await requestPermission();
}
} finally {
if (!!hasPermission && !hasPermission.canAskAgain) {
props.onPermissionRequestFailure();
}
}
}, [hasPermission, requestPermission, props.onPermissionRequestFailure]);
useEffect(() => {
if (hasPermission?.granted) {
props.onHasPermission();
}
}, [hasPermission, props.onHasPermission]);
return <CameraView
ref={cameraRef}
style={props.style}
facing={props.cameraType === CameraDirection.Front ? 'front' : 'back'}
ratio={props.ratio as CameraRatio}
onCameraReady={props.onCameraReady}
animateShutter={false}
barcodeScannerSettings={barcodeScannerSettings}
onBarcodeScanned={props.codeScanner.onBarcodeScanned}
/>;
};
export default forwardRef(Camera);

View File

@ -0,0 +1,19 @@
import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import { ViewStyle } from 'react-native';
import { BarcodeScanner } from '../utils/useBarcodeScanner';
import { CameraResult } from '../types';
export interface Props {
style: ViewStyle;
cameraType: CameraDirection;
ratio: string|undefined;
codeScanner: BarcodeScanner;
onCameraReady: ()=> void;
onPermissionRequestFailure: ()=> void;
onHasPermission: ()=> void;
}
export interface CameraRef {
takePictureAsync(): Promise<CameraResult>;
}

View File

@ -0,0 +1,88 @@
import * as React from 'react';
import CameraView from './CameraView';
import { CameraResult } from './types';
import { fireEvent, render, screen } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import TestProviderStack from '../testing/TestProviderStack';
interface WrapperProps {
onPhoto?: (result: CameraResult)=> void;
onInsertBarcode?: (text: string)=> void;
onCancel?: ()=> void;
}
const emptyFn = ()=>{};
const store = createMockReduxStore();
const CameraViewWrapper: React.FC<WrapperProps> = props => {
return <TestProviderStack store={store}>
<CameraView
style={{}}
onPhoto={props.onPhoto ?? emptyFn}
onInsertBarcode={props.onInsertBarcode ?? emptyFn}
onCancel={props.onCancel ?? emptyFn}
/>
</TestProviderStack>;
};
const rejectCameraPermission = () => {
const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' });
fireEvent.press(rejectPermissionButton);
};
const acceptCameraPermission = () => {
const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' });
fireEvent.press(acceptPermissionButton);
};
const startCamera = () => {
const startCameraButton = screen.getByRole('button', { name: 'On camera ready' });
fireEvent.press(startCameraButton);
};
const setQrCodeData = (data: string) => {
const qrCodeDataInput = screen.getByPlaceholderText('QR code data');
fireEvent.changeText(qrCodeDataInput, data);
};
describe('CameraView', () => {
test('should hide permissions error if camera permission is granted', async () => {
const view = render(<CameraViewWrapper/>);
const queryPermissionsError = () => screen.queryByText('Missing camera permission');
expect(queryPermissionsError()).toBeNull();
rejectCameraPermission();
expect(queryPermissionsError()).toBeVisible();
acceptCameraPermission();
expect(queryPermissionsError()).toBeNull();
expect(await screen.findByRole('button', { name: 'Back' })).toBeVisible();
startCamera();
expect(await screen.findByRole('button', { name: 'Take picture' })).toBeVisible();
view.unmount();
});
test('should allow inserting QR code text', async () => {
const onInsertBarcode = jest.fn();
const view = render(<CameraViewWrapper onInsertBarcode={onInsertBarcode}/>);
acceptCameraPermission();
startCamera();
const qrCodeData = 'Test!';
setQrCodeData(qrCodeData);
const qrCodeButton = await screen.findByRole('button', { name: 'QR Code' });
expect(qrCodeButton).toBeVisible();
fireEvent.press(qrCodeButton);
const addToNoteButton = await screen.findByRole('button', { name: 'Add to note' });
fireEvent.press(addToNoteButton);
expect(onInsertBarcode).toHaveBeenCalledTimes(1);
expect(onInsertBarcode).toHaveBeenCalledWith(qrCodeData);
view.unmount();
});
});

View File

@ -0,0 +1,216 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { Text, StyleSheet, Linking, View, Platform, useWindowDimensions } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { ViewStyle } from 'react-native';
import { AppState } from '../../utils/types';
import ActionButtons from './ActionButtons';
import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import Setting from '@joplin/lib/models/Setting';
import { LinkButton, PrimaryButton } from '../buttons';
import BackButtonService from '../../services/BackButtonService';
import { themeStyle } from '../global-style';
import fitRectIntoBounds from './utils/fitRectIntoBounds';
import useBarcodeScanner from './utils/useBarcodeScanner';
import ScannedBarcodes from './ScannedBarcodes';
import { CameraRef } from './Camera/types';
import Camera from './Camera';
import { CameraResult } from './types';
interface Props {
themeId: number;
style: ViewStyle;
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
onCancel: ()=> void;
onInsertBarcode: (barcodeText: string)=> void;
}
interface UseStyleProps {
themeId: number;
style: ViewStyle;
cameraRatio: string;
}
const useStyles = ({ themeId, style, cameraRatio }: UseStyleProps) => {
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const outputPositioning = useMemo((): ViewStyle => {
const ratioMatch = cameraRatio?.match(/^(\d+):(\d+)$/);
if (!ratioMatch) {
return { left: 0, top: 0 };
}
const output = fitRectIntoBounds({
width: Number(ratioMatch[2]),
height: Number(ratioMatch[1]),
}, {
width: Math.min(screenWidth, screenHeight),
height: Math.max(screenWidth, screenHeight),
});
if (screenWidth > screenHeight) {
const w = output.width;
output.width = output.height;
output.height = w;
}
return {
left: (screenWidth - output.width) / 2,
top: (screenHeight - output.height) / 2,
width: output.width,
height: output.height,
flexBasis: output.height,
flexGrow: 0,
alignContent: 'center',
};
}, [cameraRatio, screenWidth, screenHeight]);
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
container: {
backgroundColor: '#000',
...style,
},
camera: {
position: 'relative',
...outputPositioning,
...style,
},
errorContainer: {
position: 'absolute',
top: 0,
alignSelf: 'center',
backgroundColor: theme.backgroundColor,
maxWidth: 600,
padding: 28,
borderRadius: 28,
},
});
}, [themeId, style, outputPositioning]);
};
const androidRatios = ['1:1', '4:3', '16:9'];
const iOSRatios: string[] = [];
const useAvailableRatios = (): string[] => {
return Platform.OS === 'android' ? androidRatios : iOSRatios;
};
const CameraViewComponent: React.FC<Props> = props => {
const styles = useStyles(props);
const cameraRef = useRef<CameraRef|null>(null);
const [cameraReady, setCameraReady] = useState(false);
useEffect(() => {
const handler = () => {
props.onCancel();
return true;
};
BackButtonService.addHandler(handler);
return () => {
BackButtonService.removeHandler(handler);
};
}, [props.onCancel]);
const onCameraReverse = useCallback(() => {
const newDirection = props.cameraType === CameraDirection.Front ? CameraDirection.Back : CameraDirection.Front;
Setting.setValue('camera.type', newDirection);
}, [props.cameraType]);
const availableRatios = useAvailableRatios();
const onNextCameraRatio = useCallback(async () => {
const ratioIndex = Math.max(0, availableRatios.indexOf(props.cameraRatio));
Setting.setValue('camera.ratio', availableRatios[(ratioIndex + 1) % availableRatios.length]);
}, [props.cameraRatio, availableRatios]);
const codeScanner = useBarcodeScanner();
const onCameraReady = useCallback(() => {
setCameraReady(true);
}, []);
const [takingPicture, setTakingPicture] = useState(false);
const takingPictureRef = useRef(takingPicture);
takingPictureRef.current = takingPicture;
const onTakePicture = useCallback(async () => {
if (takingPictureRef.current) return;
setTakingPicture(true);
try {
const picture = await cameraRef.current.takePictureAsync();
if (picture) {
props.onPhoto(picture);
}
} finally {
setTakingPicture(false);
}
}, [props.onPhoto]);
const [permissionRequestFailed, setPermissionRequestFailed] = useState(false);
const onPermissionRequestFailure = useCallback(() => {
setPermissionRequestFailed(true);
}, []);
const onHasPermission = useCallback(() => {
setPermissionRequestFailed(false);
}, []);
let overlay;
if (permissionRequestFailed) {
overlay = <View style={styles.errorContainer}>
<Text>{_('Missing camera permission')}</Text>
<LinkButton onPress={() => Linking.openSettings()}>{_('Open settings')}</LinkButton>
<PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>
</View>;
} else {
overlay = <>
<ActionButtons
themeId={props.themeId}
onCameraReverse={onCameraReverse}
cameraDirection={props.cameraType}
cameraRatio={props.cameraRatio}
onSetCameraRatio={onNextCameraRatio}
onTakePicture={onTakePicture}
takingPicture={takingPicture}
onCancelPhoto={props.onCancel}
cameraReady={cameraReady}
/>
<ScannedBarcodes
themeId={props.themeId}
codeScanner={codeScanner}
onInsertCode={props.onInsertBarcode}
/>
</>;
}
return (
<View style={styles.container}>
<Camera
ref={cameraRef}
style={styles.camera}
cameraType={props.cameraType}
ratio={availableRatios.includes(props.cameraRatio) ? props.cameraRatio : undefined}
onCameraReady={onCameraReady}
codeScanner={codeScanner}
onPermissionRequestFailure={onPermissionRequestFailure}
onHasPermission={onHasPermission}
/>
{overlay}
</View>
);
};
const mapStateToProps = (state: AppState) => {
return {
themeId: state.settings.theme,
cameraRatio: state.settings['camera.ratio'],
cameraType: state.settings['camera.type'],
};
};
export default connect(mapStateToProps)(CameraViewComponent);

View File

@ -0,0 +1,103 @@
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Platform, ScrollView, StyleSheet, View } from 'react-native';
import { BarcodeScanner } from './utils/useBarcodeScanner';
import { LinkButton, PrimaryButton } from '../buttons';
import { _ } from '@joplin/lib/locale';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { Chip, Text } from 'react-native-paper';
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
themeId: number;
codeScanner: BarcodeScanner;
onInsertCode: (codeText: string)=> void;
}
const useStyles = () => {
return useMemo(() => {
return StyleSheet.create({
container: {
position: 'absolute',
right: 10,
top: 10,
},
spacer: {
flexGrow: 1,
},
scannedCode: {
padding: 20,
fontFamily: Platform.select({
android: 'monospace',
ios: 'Courier New',
default: undefined,
}),
},
});
}, []);
};
const ScannedBarcodes: React.FC<Props> = props => {
const styles = useStyles();
const [dialogVisible, setDialogVisible] = useState(false);
const [dismissedAtTime, setDismissedAtTime] = useState(0);
const onHideCodeNotification = useCallback(() => {
setDismissedAtTime(performance.now());
}, []);
const onShowDialog = useCallback(() => {
setDialogVisible(true);
}, []);
const onHideDialog = useCallback(() => {
setDialogVisible(false);
onHideCodeNotification();
}, [onHideCodeNotification]);
const codeScanner = props.codeScanner;
const scannedText = codeScanner.lastScan?.text;
const isLink = useMemo(() => {
return scannedText && isCallbackUrl(scannedText);
}, [scannedText]);
const onFollowLink = useCallback(() => {
setDialogVisible(false);
const data = parseCallbackUrl(scannedText);
if (data && data.params.id) {
void CommandService.instance().execute('openItem', `:/${data.params.id}`);
}
}, [scannedText]);
const onInsertText = useCallback(() => {
setDialogVisible(false);
props.onInsertCode(scannedText);
}, [scannedText, props.onInsertCode]);
const codeChipHidden = !scannedText || dialogVisible || codeScanner.lastScan.timestamp < dismissedAtTime;
const dialogOpenButton = <Chip icon='qrcode' onPress={onShowDialog} onClose={onHideCodeNotification}>
{_('QR Code')}
</Chip>;
return <View style={styles.container}>
{codeChipHidden ? null : dialogOpenButton}
<DismissibleDialog
visible={dialogVisible}
onDismiss={onHideDialog}
themeId={props.themeId}
size={DialogSize.Small}
>
<ScrollView>
<Text variant='titleMedium' role='heading'>{_('Scanned code')}</Text>
<Text
style={styles.scannedCode}
variant='labelLarge'
selectable={true}
>{scannedText}</Text>
</ScrollView>
<View style={styles.spacer}/>
{isLink ? <LinkButton onPress={onFollowLink}>{_('Follow link')}</LinkButton> : null}
<PrimaryButton onPress={onInsertText}>{_('Add to note')}</PrimaryButton>
</DismissibleDialog>
</View>;
};
export default ScannedBarcodes;

View File

@ -0,0 +1,5 @@
export interface CameraResult {
uri: string;
type: string;
}

View File

@ -0,0 +1,24 @@
export interface Rect {
width: number;
height: number;
}
const fitRectIntoBounds = (rect: Rect, bounds: Rect) => {
const rectRatio = rect.width / rect.height;
const boundsRatio = bounds.width / bounds.height;
const newDimensions: Rect = { width: 0, height: 0 };
// Rect is more landscape than bounds - fit to width
if (rectRatio > boundsRatio) {
newDimensions.width = bounds.width;
newDimensions.height = rect.height * (bounds.width / rect.width);
} else { // Rect is more portrait than bounds - fit to height
newDimensions.width = rect.width * (bounds.height / rect.height);
newDimensions.height = bounds.height;
}
return newDimensions;
};
export default fitRectIntoBounds;

View File

@ -0,0 +1,42 @@
import { useMemo, useState } from 'react';
interface ScannedData {
text: string;
timestamp: number;
}
interface BarcodeScanningResult {
type: string;
data: string;
}
export interface BarcodeScanner {
enabled: boolean;
onToggleEnabled: ()=> void;
onBarcodeScanned: (scan: BarcodeScanningResult)=> void;
lastScan: ScannedData|null;
}
const useBarcodeScanner = (): BarcodeScanner => {
const [lastScan, setLastScan] = useState<ScannedData|null>(null);
const [enabled, setEnabled] = useState(true);
return useMemo(() => {
return {
enabled,
onToggleEnabled: () => {
setEnabled(enabled => !enabled);
},
onBarcodeScanned: enabled ? (scanningResult: BarcodeScanningResult) => {
setLastScan({
text: scanningResult.data ?? 'null',
timestamp: performance.now(),
});
} : null,
lastScan,
};
}, [lastScan, enabled]);
};
export default useBarcodeScanner;

View File

@ -3,10 +3,8 @@ import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { Provider } from 'react-redux';
import NoteScreen from './Note';
import { MenuProvider } from 'react-native-popup-menu';
import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers } from '@joplin/lib/testing/test-utils';
import { waitFor as waitForWithRealTimers } from '@joplin/lib/testing/test-utils';
import Note from '@joplin/lib/models/Note';
@ -14,7 +12,6 @@ import { AppState } from '../../utils/types';
import { Store } from 'redux';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import initializeCommandService from '../../utils/initializeCommandService';
import { PaperProvider } from 'react-native-paper';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import { NoteEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
@ -29,6 +26,7 @@ import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import Setting from '@joplin/lib/models/Setting';
import Resource from '@joplin/lib/models/Resource';
import TestProviderStack from '../testing/TestProviderStack';
interface WrapperProps {
}
@ -36,13 +34,9 @@ interface WrapperProps {
let store: Store<AppState>;
const WrappedNoteScreen: React.FC<WrapperProps> = _props => {
return <MenuProvider>
<PaperProvider>
<Provider store={store}>
<NoteScreen />
</Provider>
</PaperProvider>
</MenuProvider>;
return <TestProviderStack store={store}>
<NoteScreen />
</TestProviderStack>;
};
const getNoteViewerDom = async () => {

View File

@ -38,7 +38,7 @@ import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib
import { Asset, ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import SelectDateTimeDialog from '../SelectDateTimeDialog';
import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView';
import CameraView from '../CameraView/CameraView';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor';
@ -63,6 +63,7 @@ import CommandService from '@joplin/lib/services/CommandService';
import { ResourceInfo } from '../NoteBodyViewer/hooks/useRerenderHandler';
import getImageDimensions from '../../utils/image/getImageDimensions';
import resizeImage from '../../utils/image/resizeImage';
import { CameraResult } from '../CameraView/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@ -680,6 +681,39 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
return await saveOriginalImage();
}
private async insertText(text: string) {
const newNote = { ...this.state.note };
if (this.state.mode === 'edit') {
let newText = '';
if (this.selection) {
newText = `\n${text}\n`;
const prefix = newNote.body.substring(0, this.selection.start);
const suffix = newNote.body.substring(this.selection.end);
newNote.body = `${prefix}${newText}${suffix}`;
} else {
newText = `\n${text}`;
newNote.body = `${newNote.body}\n${newText}`;
}
if (this.useEditorBeta()) {
// The beta editor needs to be explicitly informed of changes
// to the note's body
if (this.editorRef.current) {
this.editorRef.current.insertText(newText);
} else {
logger.info(`Tried to insert text ${text} to the note when the editor is not visible -- updating the note body instead.`);
}
}
} else {
newNote.body += `\n${text}`;
}
this.setState({ note: newNote });
return newNote;
}
public async attachFile(pickerResponse: Asset, fileType: string): Promise<ResourceEntity|null> {
if (!pickerResponse) {
// User has cancelled
@ -753,36 +787,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
resource = await Resource.save(resource, { isNew: true });
const resourceTag = Resource.markupTag(resource);
const newNote = { ...this.state.note };
if (this.state.mode === 'edit') {
let newText = '';
if (this.selection) {
newText = `\n${resourceTag}\n`;
const prefix = newNote.body.substring(0, this.selection.start);
const suffix = newNote.body.substring(this.selection.end);
newNote.body = `${prefix}${newText}${suffix}`;
} else {
newText = `\n${resourceTag}`;
newNote.body = `${newNote.body}\n${newText}`;
}
if (this.useEditorBeta()) {
// The beta editor needs to be explicitly informed of changes
// to the note's body
if (this.editorRef.current) {
this.editorRef.current.insertText(newText);
} else {
logger.info(`Tried to attach resource ${resource.id} to the note when the editor is not visible -- updating the note body instead.`);
}
}
} else {
newNote.body += `\n${resourceTag}`;
}
this.setState({ note: newNote });
const newNote = await this.insertText(resourceTag);
void this.refreshResource(resource, newNote.body);
@ -821,19 +826,20 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private cameraView_onPhoto(data: any) {
private cameraView_onPhoto(data: CameraResult) {
void this.attachFile(
{
uri: data.uri,
type: 'image/jpg',
},
data,
'image',
);
this.setState({ showCamera: false });
}
private cameraView_onInsertBarcode = (data: string) => {
this.setState({ showCamera: false });
void this.insertText(data);
};
private cameraView_onCancel() {
this.setState({ showCamera: false });
}
@ -1440,7 +1446,12 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
const isTodo = !!Number(note.is_todo);
if (this.state.showCamera) {
return <CameraView themeId={this.props.themeId} style={{ flex: 1 }} onPhoto={this.cameraView_onPhoto} onCancel={this.cameraView_onCancel} />;
return <CameraView
style={{ flex: 1 }}
onPhoto={this.cameraView_onPhoto}
onInsertBarcode={this.cameraView_onInsertBarcode}
onCancel={this.cameraView_onCancel}
/>;
} else if (this.state.showImageEditor) {
return <ImageEditor
resourceFilename={this.state.imageEditorResourceFilepath}

View File

@ -0,0 +1,23 @@
import * as React from 'react';
import { PaperProvider } from 'react-native-paper';
import { MenuProvider } from 'react-native-popup-menu';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
interface Props {
store: Store<AppState>;
children: React.ReactNode;
}
const TestProviderStack: React.FC<Props> = props => {
return <Provider store={props.store}>
<MenuProvider>
<PaperProvider>
{props.children}
</PaperProvider>
</MenuProvider>
</Provider>;
};
export default TestProviderStack;

View File

@ -1,3 +1,4 @@
#import <Expo/Expo.h>
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View File

@ -11,6 +11,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
46E31F54C547C341F605BB66 /* libPods-Joplin.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A5E1CD825FABD6C4E704EA54 /* libPods-Joplin.a */; };
4C036D13E81D8DB9640B0DC1 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF14612B39CE1556A9A31631 /* ExpoModulesProvider.swift */; };
4D122473270878D700DE23E8 /* wtf.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D122472270878D700DE23E8 /* wtf.swift */; };
5E556FC75AECECB13464A724 /* libPods-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FAC957496DFD2368FFE3C360 /* libPods-ShareExtension.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
@ -79,6 +80,7 @@
AE82E4B02599FA3A0013551B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B61798F36B3BC123BF8EA4D9 /* libPods-Joplin-tvOS.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Joplin-tvOS.a"; sourceTree = BUILT_PRODUCTS_DIR; };
BAD33BAC2BE9A08300E9F46A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
CF14612B39CE1556A9A31631 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Joplin/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; };
F69B873C692CE22F1C4C9264 /* libPods-Joplin-JoplinTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Joplin-JoplinTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
@ -153,6 +155,14 @@
name = Frameworks;
sourceTree = "<group>";
};
336B5BD29D5BD1737E707672 /* Joplin */ = {
isa = PBXGroup;
children = (
CF14612B39CE1556A9A31631 /* ExpoModulesProvider.swift */,
);
name = Joplin;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
@ -171,6 +181,7 @@
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
9CDB1D9DB6483D893504BFCB /* Pods */,
E67B064D6F5F7E8B1080FDB0 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
@ -215,6 +226,14 @@
path = ShareExtension;
sourceTree = "<group>";
};
E67B064D6F5F7E8B1080FDB0 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
336B5BD29D5BD1737E707672 /* Joplin */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -223,6 +242,7 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Joplin" */;
buildPhases = (
335ACF4DE85695BEBB18D8A3 /* [CP] Check Pods Manifest.lock */,
EB61CD887618E406C80EBC43 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
@ -404,6 +424,9 @@
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf",
@ -428,6 +451,9 @@
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf",
@ -455,6 +481,25 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-resources.sh\"\n";
showEnvVarsInLog = 0;
};
EB61CD887618E406C80EBC43 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Joplin/expo-configure-project.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -465,6 +510,7 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
4D122473270878D700DE23E8 /* wtf.swift in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
4C036D13E81D8DB9640B0DC1 /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -519,6 +565,7 @@
"-weak_framework",
SwiftUI,
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin;
PRODUCT_NAME = Joplin;
SWIFT_OBJC_BRIDGING_HEADER = "Joplin-Bridging-Header.h";
@ -549,6 +596,7 @@
"-weak_framework",
SwiftUI,
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin;
PRODUCT_NAME = Joplin;
SWIFT_OBJC_BRIDGING_HEADER = "Joplin-Bridging-Header.h";
@ -743,6 +791,7 @@
"-weak_framework",
SwiftUI,
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -780,6 +829,7 @@
"-weak_framework",
SwiftUI,
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@ -1,7 +1,8 @@
#import <RCTAppDelegate.h>
#import <Expo/Expo.h>
#import <UIKit/UIKit.h>
#import <UserNotifications/UNUserNotificationCenter.h>
@interface AppDelegate : RCTAppDelegate
@interface AppDelegate : EXAppDelegateWrapper
@end

View File

@ -1,3 +1,4 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
'require.resolve(
@ -23,6 +24,14 @@ if linkage != nil
end
target 'Joplin' do
use_expo_modules!
post_integrate do |installer|
begin
expo_patch_react_imports!(installer)
rescue => e
Pod::UI.warn e
end
end
config = use_native_modules!
use_react_native!(

View File

@ -1,6 +1,43 @@
PODS:
- boost (1.83.0)
- DoubleConversion (1.1.6)
- EXConstants (16.0.2):
- ExpoModulesCore
- Expo (51.0.0):
- ExpoModulesCore
- ExpoAsset (10.0.10):
- ExpoModulesCore
- ExpoCamera (15.0.16):
- ExpoModulesCore
- ZXingObjC/OneD
- ZXingObjC/PDF417
- ExpoFileSystem (17.0.1):
- ExpoModulesCore
- ExpoFont (12.0.10):
- ExpoModulesCore
- ExpoModulesCore (1.12.9):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.01.01.00)
- RCTRequired
- RCTTypeSafety
- React-Codegen
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- FBLazyVector (0.74.1)
- fmt (9.1.0)
- glog (0.3.5)
@ -941,14 +978,6 @@ PODS:
- React-debug
- react-native-alarm-notification (3.1.0):
- React
- react-native-camera (4.2.1):
- React-Core
- react-native-camera/RCT (= 4.2.1)
- react-native-camera/RN (= 4.2.1)
- react-native-camera/RCT (4.2.1):
- React-Core
- react-native-camera/RN (4.2.1):
- React-Core
- react-native-document-picker (9.3.0):
- React-Core
- react-native-fingerprint-scanner (6.0.0):
@ -1005,7 +1034,7 @@ PODS:
- React
- react-native-saf-x (3.1.0):
- React-Core
- react-native-safe-area-context (4.10.7):
- react-native-safe-area-context (4.10.8):
- React-Core
- react-native-slider (4.4.4):
- DoubleConversion
@ -1032,7 +1061,7 @@ PODS:
- React-Core
- react-native-version-info (1.1.1):
- React-Core
- react-native-webview (13.10.4):
- react-native-webview (13.10.5):
- DoubleConversion
- glog
- hermes-engine
@ -1288,7 +1317,7 @@ PODS:
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (8.0.1):
- RNDateTimePicker (8.1.1):
- React-Core
- RNDeviceInfo (10.14.0):
- React-Core
@ -1337,10 +1366,22 @@ PODS:
- SocketRocket (0.7.0)
- SSZipArchive (2.4.3)
- Yoga (0.0.0)
- ZXingObjC/Core (3.6.9)
- ZXingObjC/OneD (3.6.9):
- ZXingObjC/Core
- ZXingObjC/PDF417 (3.6.9):
- ZXingObjC/Core
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- Expo (from `../node_modules/expo`)
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
@ -1374,7 +1415,6 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- "react-native-alarm-notification (from `../node_modules/@joplin/react-native-alarm-notification`)"
- react-native-camera (from `../node_modules/react-native-camera`)
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
- react-native-fingerprint-scanner (from `../node_modules/react-native-fingerprint-scanner`)
- "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)"
@ -1432,12 +1472,27 @@ SPEC REPOS:
trunk:
- SocketRocket
- SSZipArchive
- ZXingObjC
EXTERNAL SOURCES:
boost:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXConstants:
:path: "../node_modules/expo-constants/ios"
Expo:
:path: "../node_modules/expo"
ExpoAsset:
:path: "../node_modules/expo-asset/ios"
ExpoCamera:
:path: "../node_modules/expo-camera/ios"
ExpoFileSystem:
:path: "../node_modules/expo-file-system/ios"
ExpoFont:
:path: "../node_modules/expo-font/ios"
ExpoModulesCore:
:path: "../node_modules/expo-modules-core"
FBLazyVector:
:path: "../node_modules/react-native/Libraries/FBLazyVector"
fmt:
@ -1501,8 +1556,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
react-native-alarm-notification:
:path: "../node_modules/@joplin/react-native-alarm-notification"
react-native-camera:
:path: "../node_modules/react-native-camera"
react-native-document-picker:
:path: "../node_modules/react-native-document-picker"
react-native-fingerprint-scanner:
@ -1611,6 +1664,13 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: d3f49c53809116a5d38da093a8aa78bf551aed09
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
EXConstants: 409690fbfd5afea964e5e9d6c4eb2c2b59222c59
Expo: 17eb02cbf2ac0db49a6bc0c80a6b635a900d4f60
ExpoAsset: 323700f291684f110fb55f0d4022a3362ea9f875
ExpoCamera: 929be541d1c1319fcf32f9f5d9df8b97804346b5
ExpoFileSystem: 80bfe850b1f9922c16905822ecbf97acd711dc51
ExpoFont: 00756e6c796d8f7ee8d211e29c8b619e75cbf238
ExpoModulesCore: 070bbb7162641709919cbf50230f0b535b8190b1
FBLazyVector: 898d14d17bf19e2435cafd9ea2a1033efe445709
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
@ -1642,7 +1702,6 @@ SPEC CHECKSUMS:
React-logger: 7e7403a2b14c97f847d90763af76b84b152b6fce
React-Mapbuffer: 11029dcd47c5c9e057a4092ab9c2a8d10a496a33
react-native-alarm-notification: 43183613222c563c071f2c726624f9f6f06e605d
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
react-native-document-picker: 5b97e24a7f1a1e4a50a72c540a043f32d29a70a2
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
react-native-geolocation: fe0562c94eb0b6334f266aea717448dfd9b08cd0
@ -1652,11 +1711,11 @@ SPEC CHECKSUMS:
react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: 7dfb7e614512c82dba2dea3401509e1c44f3d1f9
react-native-safe-area-context: 422017db8bcabbada9ad607d010996c56713234c
react-native-safe-area-context: b7daa1a8df36095a032dff095a1ea8963cb48371
react-native-slider: 03f213d3ffbf919b16298c7896c1b60101d8e137
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
react-native-webview: 596fb33d67a3cde5a74bf1f6b4c28d3543477fdd
react-native-webview: 553abd09f58e340fdc7746c9e2ae096839e99911
React-nativeconfig: b0073a590774e8b35192fead188a36d1dca23dec
React-NativeModulesApple: df46ff3e3de5b842b30b4ca8a6caae6d7c8ab09f
React-perflogger: 3d31e0d1e8ad891e43a09ac70b7b17a79773003a
@ -1683,7 +1742,7 @@ SPEC CHECKSUMS:
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNCClipboard: 0a720adef5ec193aa0e3de24c3977222c7e52a37
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
RNDateTimePicker: b6a9b35a785ecbe12b4e7d6de5439d0aa4614146
RNDateTimePicker: 430174392d275f0ffefb627d04f0f8677f667fed
RNDeviceInfo: 59344c19152c4b2b32283005f9737c5c64b42fba
RNExitApp: 00036cabe7bacbb413d276d5520bf74ba39afa6a
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
@ -1697,7 +1756,8 @@ SPEC CHECKSUMS:
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
Yoga: 348f8b538c3ed4423eb58a8e5730feec50bce372
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 0b954caebefad4e9dc123f5491a2649c02c896ea
PODFILE CHECKSUM: 11a2f8ebab99f816b8905858bff8a86a196b1f7e
COCOAPODS: 1.15.2

View File

@ -4,14 +4,6 @@
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
@ -25,6 +17,8 @@
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>0A2A.1</string>
<string>3B52.1</string>
<string>C617.1</string>
</array>
</dict>
@ -33,8 +27,16 @@
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>85F4.1</string>
<string>E174.1</string>
<string>85F4.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>

View File

@ -55,6 +55,10 @@ jest.mock('./components/ExtendedWebView', () => {
return require('./components/ExtendedWebView/index.jest.js');
});
jest.mock('./components/CameraView/Camera', () => {
return require('./components/CameraView/Camera/index.jest');
});
jest.mock('@react-native-clipboard/clipboard', () => {
return { default: { getString: jest.fn(), setString: jest.fn() } };
});

View File

@ -39,6 +39,8 @@
"crypto-browserify": "3.12.0",
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "51.0.0",
"expo-camera": "15.0.16",
"lodash": "4.17.21",
"md5": "2.3.0",
"path-browserify": "1.0.1",
@ -46,7 +48,6 @@
"punycode": "2.3.1",
"react": "18.3.1",
"react-native": "0.74.1",
"react-native-camera": "4.2.1",
"react-native-device-info": "10.14.0",
"react-native-dialogbox": "0.6.10",
"react-native-document-picker": "9.3.0",
@ -134,5 +135,13 @@
},
"engines": {
"node": ">=18"
},
"expo": {
"autolinking": {
"exclude": [
"expo-application",
"expo-keep-awake"
]
}
}
}

View File

@ -73,6 +73,7 @@ module.exports = {
'react-native-zip-archive': emptyLibraryMock,
'react-native-document-picker': emptyLibraryMock,
'react-native-exit-app': emptyLibraryMock,
'expo-camera': emptyLibraryMock,
// Workaround for applying serviceworker types to a single file.
// See https://joshuatz.com/posts/2021/strongly-typed-service-workers/.
@ -100,6 +101,7 @@ module.exports = {
'timers': require.resolve('timers-browserify'),
'path': require.resolve('path-browserify'),
'stream': require.resolve('stream-browserify'),
'crypto': require.resolve('crypto-browserify'),
},
},

View File

@ -14,6 +14,11 @@ const customCssFilePath = (Setting: typeof SettingType, filename: string): strin
return `${Setting.value('rootProfileDir')}/${filename}`;
};
export enum CameraDirection {
Back,
Front,
}
const builtInMetadata = (Setting: typeof SettingType) => {
const platform = shim.platformName();
const mobilePlatform = shim.mobilePlatform();
@ -1436,7 +1441,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
'welcome.wasBuilt': { value: false, type: SettingItemType.Bool, public: false },
'welcome.enabled': { value: true, type: SettingItemType.Bool, public: false },
'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] },
'camera.type': { value: CameraDirection.Back, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] },
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] },
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, isGlobal: true, storage: SettingStorage.File, public: false },

View File

@ -136,3 +136,6 @@ Backblaze
onnx
onnxruntime
treeitem
qrcode
Rocketbook
datamatrix

2070
yarn.lock

File diff suppressed because it is too large Load Diff