/* eslint-disable @typescript-eslint/explicit-member-accessibility */ import Slider from '@react-native-community/slider'; const React = require('react'); import { Platform, Linking, View, Switch, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid, TouchableNativeFeedback } from 'react-native'; import Setting, { AppType } from '@joplin/lib/models/Setting'; import NavService from '@joplin/lib/services/NavService'; import ReportService from '@joplin/lib/services/ReportService'; import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine'; import checkPermissions from '../../../utils/checkPermissions'; import time from '@joplin/lib/time'; import shim from '@joplin/lib/shim'; import setIgnoreTlsErrors from '../../../utils/TlsUtils'; import { reg } from '@joplin/lib/registry'; import { State } from '@joplin/lib/reducer'; const { BackButtonService } = require('../../../services/back-button.js'); const VersionInfo = require('react-native-version-info').default; const { connect } = require('react-redux'); import ScreenHeader from '../../ScreenHeader'; const { _ } = require('@joplin/lib/locale'); const { BaseScreenComponent } = require('../../base-screen.js'); const { Dropdown } = require('../../Dropdown'); const { themeStyle } = require('../../global-style.js'); const shared = require('@joplin/lib/components/shared/config-shared.js'); import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import { openDocumentTree } from '@joplin/react-native-saf-x'; import biometricAuthenticate from '../../biometrics/biometricAuthenticate'; import configScreenStyles from './configScreenStyles'; import NoteExportButton from './NoteExportSection/NoteExportButton'; import ConfigScreenButton from './ConfigScreenButton'; class ConfigScreenComponent extends BaseScreenComponent { public static navigationOptions(): any { return { header: null }; } private componentsY_: Record = {}; public constructor() { super(); this.styles_ = {}; this.state = { creatingReport: false, profileExportStatus: 'idle', profileExportPath: '', fileSystemSyncPath: Setting.value('sync.2.path'), }; this.scrollViewRef_ = React.createRef(); shared.init(this, reg); this.selectDirectoryButtonPress = async () => { try { const doc = await openDocumentTree(true); if (doc?.uri) { this.setState({ fileSystemSyncPath: doc.uri }); shared.updateSettingValue(this, 'sync.2.path', doc.uri); } else { throw new Error('User cancelled operation'); } } catch (e) { reg.logger().info('Didn\'t pick sync dir: ', e); } }; this.checkSyncConfig_ = async () => { // to ignore TLS erros we need to chage the global state of the app, if the check fails we need to restore the original state // this call sets the new value and returns the previous one which we can use later to revert the change const prevIgnoreTlsErrors = await setIgnoreTlsErrors(this.state.settings['net.ignoreTlsErrors']); const result = await shared.checkSyncConfig(this, this.state.settings); if (!result || !result.ok) { await setIgnoreTlsErrors(prevIgnoreTlsErrors); } }; this.e2eeConfig_ = () => { void NavService.go('EncryptionConfig'); }; this.saveButton_press = async () => { if (this.state.changedSettingKeys.includes('sync.target') && this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('filesystem')) { if (Platform.OS === 'android') { if (Platform.Version < 29) { if (!(await this.checkFilesystemPermission())) { Alert.alert(_('Warning'), _('In order to use file system synchronisation your permission to write to external storage is required.')); } } } // Save settings anyway, even if permission has not been granted } // changedSettingKeys is cleared in shared.saveSettings so reading it now const setIgnoreTlsErrors = this.state.changedSettingKeys.includes('net.ignoreTlsErrors'); await shared.saveSettings(this); if (setIgnoreTlsErrors) { await setIgnoreTlsErrors(Setting.value('net.ignoreTlsErrors')); } }; this.saveButton_press = this.saveButton_press.bind(this); this.syncStatusButtonPress_ = () => { void NavService.go('Status'); }; this.manageProfilesButtonPress_ = () => { this.props.dispatch({ type: 'NAV_GO', routeName: 'ProfileSwitcher', }); }; this.exportDebugButtonPress_ = async () => { this.setState({ creatingReport: true }); const service = new ReportService(); const logItems = await reg.logger().lastEntries(null); const logItemRows = [['Date', 'Level', 'Message']]; for (let i = 0; i < logItems.length; i++) { const item = logItems[i]; logItemRows.push([time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'), item.level, item.message]); } const logItemCsv = service.csvCreate(logItemRows); const itemListCsv = await service.basicItemList({ format: 'csv' }); const externalDir = await shim.fsDriver().getExternalDirectoryPath(); if (!externalDir) { this.setState({ creatingReport: false }); return; } const filePath = `${externalDir}/syncReport-${new Date().getTime()}.txt`; const finalText = [logItemCsv, itemListCsv].join('\n================================================================================\n'); await shim.fsDriver().writeFile(filePath, finalText, 'utf8'); alert(`Debug report exported to ${filePath}`); this.setState({ creatingReport: false }); }; this.fixSearchEngineIndexButtonPress_ = async () => { this.setState({ fixingSearchIndex: true }); await SearchEngine.instance().rebuildIndex(); this.setState({ fixingSearchIndex: false }); }; this.exportProfileButtonPress_ = async () => { const externalDir = await shim.fsDriver().getExternalDirectoryPath(); if (!externalDir) { return; } const p = this.state.profileExportPath ? this.state.profileExportPath : `${externalDir}/JoplinProfileExport`; this.setState({ profileExportStatus: 'prompt', profileExportPath: p, }); }; this.exportProfileButtonPress2_ = async () => { this.setState({ profileExportStatus: 'exporting' }); const dbPath = '/data/data/net.cozic.joplin/databases'; const exportPath = this.state.profileExportPath; const resourcePath = `${exportPath}/resources`; try { const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE); if (response !== PermissionsAndroid.RESULTS.GRANTED) { throw new Error('Permission denied'); } const copyFiles = async (source: string, dest: string) => { await shim.fsDriver().mkdir(dest); const files = await shim.fsDriver().readDirStats(source); for (const file of files) { const source_ = `${source}/${file.path}`; const dest_ = `${dest}/${file.path}`; if (!file.isDirectory()) { reg.logger().info(`Copying profile: ${source_} => ${dest_}`); await shim.fsDriver().copy(source_, dest_); } else { await copyFiles(source_, dest_); } } }; await copyFiles(dbPath, exportPath); await copyFiles(Setting.value('resourceDir'), resourcePath); alert('Profile has been exported!'); } catch (error) { alert(`Could not export files: ${error.message}`); } finally { this.setState({ profileExportStatus: 'idle' }); } }; this.logButtonPress_ = () => { void NavService.go('Log'); }; this.handleSetting = this.handleSetting.bind(this); } public async checkFilesystemPermission() { if (Platform.OS !== 'android') { // Not implemented yet return true; } return await checkPermissions(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, { title: _('Information'), message: _('In order to use file system synchronisation your permission to write to external storage is required.'), buttonPositive: _('OK'), }); } public UNSAFE_componentWillMount() { this.setState({ settings: this.props.settings }); } public styles() { const themeId = this.props.themeId; if (this.styles_[themeId]) return this.styles_[themeId]; this.styles_ = {}; this.styles_[themeId] = configScreenStyles(themeId); return this.styles_[themeId]; } private onHeaderLayout(key: string, event: any) { const layout = event.nativeEvent.layout; this.componentsY_[`header_${key}`] = layout.y; } private onSectionLayout(key: string, event: any) { const layout = event.nativeEvent.layout; this.componentsY_[`section_${key}`] = layout.y; } private componentY(key: string): number { if ((`section_${key}`) in this.componentsY_) return this.componentsY_[`section_${key}`]; if ((`header_${key}`) in this.componentsY_) return this.componentsY_[`header_${key}`]; console.error(`ConfigScreen: Could not find key to scroll to: ${key}`); return 0; } private handleBackButtonPress = (): boolean => { const goBack = async () => { BackButtonService.removeHandler(this.handleBackButtonPress); await BackButtonService.back(); }; if (this.state.changedSettingKeys.length > 0) { const dialogTitle: string|null = null; Alert.alert( dialogTitle, _('There are unsaved changes.'), [{ text: _('Save changes'), onPress: async () => { await this.saveButton_press(); await goBack(); }, }, { text: _('Discard changes'), onPress: goBack, }] ); return true; } return false; }; public componentDidMount() { if (this.props.navigation.state.sectionName) { setTimeout(() => { this.scrollViewRef_.current.scrollTo({ x: 0, y: this.componentY(this.props.navigation.state.sectionName), animated: true, }); }, 200); } BackButtonService.addHandler(this.handleBackButtonPress); } public componentWillUnmount() { BackButtonService.removeHandler(this.handleBackButtonPress); } public renderHeader(key: string, title: string) { const theme = themeStyle(this.props.themeId); return ( this.onHeaderLayout(key, event)}> {title} ); } private renderButton(key: string, title: string, clickHandler: ()=> void, options: any = null) { return ( ); } public sectionToComponent(key: string, section: any, settings: any) { const settingComps = []; for (let i = 0; i < section.metadatas.length; i++) { const md = section.metadatas[i]; if (section.name === 'sync' && md.key === 'sync.resourceDownloadMode') { const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']); if (syncTargetMd.supportsConfigCheck) { const messages = shared.checkSyncConfigMessages(this); const statusComp = !messages.length ? null : ( {messages[0]} {messages.length >= 1 ? ( {messages[1]} ) : null} ); settingComps.push(this.renderButton('check_sync_config_button', _('Check synchronisation configuration'), this.checkSyncConfig_, { statusComp: statusComp })); } } const settingComp = this.settingToComponent(md.key, settings[md.key]); settingComps.push(settingComp); } if (section.name === 'sync') { settingComps.push(this.renderButton('e2ee_config_button', _('Encryption Config'), this.e2eeConfig_)); } if (!settingComps.length) return null; return ( this.onSectionLayout(key, event)}> {this.renderHeader(section.name, Setting.sectionNameToLabel(section.name))} {settingComps} ); } // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied private renderToggle(key: string, label: string, value: any, updateSettingValue: Function, descriptionComp: any = null) { const theme = themeStyle(this.props.themeId); return ( {label} void updateSettingValue(key, value)} /> {descriptionComp} ); } private containerStyle(hasDescription: boolean): any { return !hasDescription ? this.styles().settingContainer : this.styles().settingContainerNoBottomBorder; } private async handleSetting(key: string, value: any): Promise { // When the user tries to enable biometrics unlock, we ask for the // fingerprint or Face ID, and if it's correct we save immediately. If // it's not, we don't turn on the setting. if (key === 'security.biometricsEnabled' && !!value) { try { await biometricAuthenticate(); shared.updateSettingValue(this, key, value, async () => await this.saveButton_press()); } catch (error) { shared.updateSettingValue(this, key, false); Alert.alert(error.message); } return true; } if (key === 'security.biometricsEnabled' && !value) { shared.updateSettingValue(this, key, value, async () => await this.saveButton_press()); return true; } return false; } public settingToComponent(key: string, value: any) { const themeId = this.props.themeId; const theme = themeStyle(themeId); const output: any = null; const updateSettingValue = async (key: string, value: any) => { const handled = await this.handleSetting(key, value); if (!handled) shared.updateSettingValue(this, key, value); }; const md = Setting.settingMetadata(key); const settingDescription = md.description ? md.description() : ''; const descriptionComp = !settingDescription ? null : {settingDescription}; const containerStyle = this.containerStyle(!!settingDescription); if (md.isEnum) { value = value.toString(); const items = Setting.enumOptionsToValueLabels(md.options(), md.optionsOrder ? md.optionsOrder() : []); return ( {md.label()} { void updateSettingValue(key, itemValue); }} /> {descriptionComp} ); } else if (md.type === Setting.TYPE_BOOL) { return this.renderToggle(key, md.label(), value, updateSettingValue, descriptionComp); // return ( // // // // {md.label()} // // updateSettingValue(key, value)} /> // // {descriptionComp} // // ); } else if (md.type === Setting.TYPE_INT) { const unitLabel = md.unitLabel ? md.unitLabel(value) : value; const minimum = 'minimum' in md ? md.minimum : 0; const maximum = 'maximum' in md ? md.maximum : 10; // Note: Do NOT add the minimumTrackTintColor and maximumTrackTintColor props // on the Slider as they are buggy and can crash the app on certain devices. // https://github.com/laurent22/joplin/issues/2733 // https://github.com/react-native-community/react-native-slider/issues/161 return ( {md.label()} {unitLabel} void updateSettingValue(key, value)} /> ); } else if (md.type === Setting.TYPE_STRING) { if (md.key === 'sync.2.path' && shim.fsDriver().isUsingAndroidSAF()) { return ( {md.label()} {this.state.fileSystemSyncPath} ); } return ( {md.label()} void updateSettingValue(key, value)} secureTextEntry={!!md.secure} /> {descriptionComp} ); } else { // throw new Error('Unsupported setting type: ' + md.type); } return output; } private renderFeatureFlags(settings: any, featureFlagKeys: string[]): any[] { const updateSettingValue = (key: string, value: any) => { return shared.updateSettingValue(this, key, value); }; const output: any[] = []; for (const key of featureFlagKeys) { output.push(this.renderToggle(key, key, settings[key], updateSettingValue)); } return output; } public render() { const settings = this.state.settings; const theme = themeStyle(this.props.themeId); const settingComps = shared.settingsToComponents2(this, 'mobile', settings); settingComps.push(this.renderHeader('tools', _('Tools'))); settingComps.push(this.renderButton('profiles_buttons', _('Manage profiles'), this.manageProfilesButtonPress_)); settingComps.push(this.renderButton('status_button', _('Sync Status'), this.syncStatusButtonPress_)); settingComps.push(this.renderButton('log_button', _('Log'), this.logButtonPress_)); settingComps.push(this.renderButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') })); settingComps.push(this.renderHeader('export', _('Export'))); settingComps.push(); if (shim.mobilePlatform() === 'android') { settingComps.push(this.renderButton('export_report_button', this.state.creatingReport ? _('Creating report...') : _('Export Debug Report'), this.exportDebugButtonPress_, { disabled: this.state.creatingReport })); settingComps.push(this.renderButton('export_data', this.state.profileExportStatus === 'exporting' ? _('Exporting profile...') : _('Export profile'), this.exportProfileButtonPress_, { disabled: this.state.profileExportStatus === 'exporting', description: _('For debugging purpose only: export your profile to an external SD card.') })); if (this.state.profileExportStatus === 'prompt') { const profileExportPrompt = ( Path: this.setState({ profileExportPath: event.nativeEvent.text })} value={this.state.profileExportPath} placeholder="/path/to/sdcard" keyboardAppearance={theme.keyboardAppearance} />