diff --git a/.gitignore b/.gitignore
index 93edbce..02e8b2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,4 +73,6 @@ android/
.env
-# @end expo-cli
\ No newline at end of file
+# @end expo-cli
+
+.eslintcache
\ No newline at end of file
diff --git a/app.config.js b/app.config.js
index 7fc2dff..2fba269 100644
--- a/app.config.js
+++ b/app.config.js
@@ -27,6 +27,7 @@ module.exports = {
plugins: [
'expo-localization',
'@morrowdigital/watermelondb-expo-plugin',
+ 'expo-document-picker',
[
'expo-build-properties',
{
diff --git a/app/(home)/index.tsx b/app/(home)/index.tsx
index 40f49c3..d32599d 100644
--- a/app/(home)/index.tsx
+++ b/app/(home)/index.tsx
@@ -29,7 +29,7 @@ const Home: FC = () => {
return (
<>
-
+ {!!sites.length && }
>
);
diff --git a/app/settings/export.tsx b/app/settings/export.tsx
new file mode 100644
index 0000000..1e053d9
--- /dev/null
+++ b/app/settings/export.tsx
@@ -0,0 +1,93 @@
+import { Trans } from '@lingui/macro';
+import * as FileSystem from 'expo-file-system';
+import * as Sharing from 'expo-sharing';
+import React, { FC } from 'react';
+import { Dimensions, StyleSheet, View } from 'react-native';
+import { Button, MD3Theme, useTheme } from 'react-native-paper';
+import { ExportIcon } from '../../src/icons/Export';
+import Site from '../../src/models/Site';
+import { useDB } from '../../src/providers/DatabaseProvider';
+import Container from '../../src/ui/Container';
+import Text from '../../src/ui/Text';
+import { format } from 'date-fns';
+
+const SettingsExport: FC = () => {
+ const { listSites } = useDB();
+ const theme = useTheme();
+ const styles = getStyles(theme);
+
+ const downloadStringAsFile = async () => {
+ const sites = await listSites();
+ const content = JSON.stringify(
+ sites.map(
+ site =>
+ ({
+ label: site.label,
+ secret: site.secret,
+ algorithm: site.algorithm,
+ digits: site.digits,
+ issuer: site.issuer,
+ type: site.type,
+ period: site.period,
+ } as Site),
+ ),
+ null,
+ 2,
+ );
+
+ const fileUri =
+ (FileSystem.documentDirectory || '') +
+ 'shield-authenticator_' +
+ format(Date.now(), 'yyyy-MM-dd-HH-mm-ss') +
+ '.json';
+ await FileSystem.writeAsStringAsync(fileUri, content);
+
+ const isAvailable = await Sharing.isAvailableAsync();
+ if (!isAvailable) {
+ alert('Sharing is not available on your device.');
+ return;
+ }
+
+ await Sharing.shareAsync(fileUri);
+ };
+
+ return (
+
+
+
+ Backup your data
+
+
+
+ To ensure that you don't lose your saved data, you can download a
+ file of the sites and restore them at a later time
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const getStyles = (theme: MD3Theme) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 0,
+ margin: 0,
+ },
+ buttonContainer: {
+ marginTop: 24,
+ },
+ });
+
+export default SettingsExport;
diff --git a/app/settings/import.tsx b/app/settings/import.tsx
new file mode 100644
index 0000000..e98d3d9
--- /dev/null
+++ b/app/settings/import.tsx
@@ -0,0 +1,123 @@
+import { Plural, Trans } from '@lingui/macro';
+import * as FileSystem from 'expo-file-system';
+import * as Sharing from 'expo-sharing';
+import React, { FC, useState } from 'react';
+import { Dimensions, StyleSheet, View } from 'react-native';
+import { Button, MD3Theme, ProgressBar, useTheme } from 'react-native-paper';
+import { ImportIcon } from '../../src/icons/Import';
+import Site from '../../src/models/Site';
+import { useDB } from '../../src/providers/DatabaseProvider';
+import Container from '../../src/ui/Container';
+import Text from '../../src/ui/Text';
+import * as DocumentPicker from 'expo-document-picker';
+import { OtpRecord } from '../../src/types';
+
+const SettingsImport: FC = () => {
+ const { newSite } = useDB();
+ const theme = useTheme();
+ const styles = getStyles(theme);
+ const [processing, setProcessing] = useState(false);
+ const [numberOfSites, setNumberOfSites] = useState(0);
+ const [sitesProcessed, setSitesProcessed] = useState(0);
+
+ const loadBackupFile = async () => {
+ const result = await DocumentPicker.getDocumentAsync({});
+ if (result.type === 'success') {
+ const content = await FileSystem.readAsStringAsync(result.uri);
+ try {
+ const sites = JSON.parse(content) as Site[];
+ if (!sites.length) {
+ throw new Error();
+ }
+ setNumberOfSites(sites.length);
+ setProcessing(true);
+ for (let i = 0; i < sites.length; i++) {
+ const site = sites[i];
+ await newSite(
+ {
+ algorithm: site.algorithm,
+ digits: site.digits,
+ label: site.label,
+ period: site.period,
+ secret: site.secret,
+ type: site.type,
+ issuer: site.issuer,
+ } as OtpRecord,
+ false,
+ );
+ setSitesProcessed(value => value + 1);
+ }
+ } catch (e) {
+ alert('The format of the file is not valid');
+ }
+ }
+ };
+
+ return (
+
+
+
+ Restore sites
+
+
+
+ If you want to restore your saved sites from a backup, you can load
+ the file stored on your device. It's important to note that the
+ existing sites will remain unchanged and won't be replaced
+
+
+
+
+ {processing && (
+
+
+ {sitesProcessed !== numberOfSites && Processing}
+ {sitesProcessed === numberOfSites && Completed}
+
+
+
+
+
+ Processed 1 site of {numberOfSites}}
+ other={Processed # sites of {numberOfSites}}
+ />
+
+
+ )}
+ {!processing && (
+
+
+
+ )}
+
+
+ );
+};
+
+const getStyles = (theme: MD3Theme) =>
+ StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 0,
+ margin: 0,
+ },
+ buttonContainer: {
+ marginTop: 24,
+ },
+ processContainer: {
+ marginVertical: 16,
+ },
+ });
+
+export default SettingsImport;
diff --git a/build/latest.apk b/build/latest.apk
index d801f52..11b3525 100644
Binary files a/build/latest.apk and b/build/latest.apk differ
diff --git a/package-lock.json b/package-lock.json
index 259ca03..1fd0ddc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@react-navigation/native": "^6.1.4",
"@react-navigation/stack": "^6.3.14",
"@shopify/flash-list": "1.4.0",
+ "date-fns": "^2.29.3",
"dotenv": "^16.0.3",
"expo": "~48.0.6",
"expo-background-fetch": "~11.1.1",
@@ -26,12 +27,15 @@
"expo-camera": "~13.2.1",
"expo-clipboard": "~4.1.2",
"expo-constants": "~14.2.1",
+ "expo-document-picker": "~11.2.2",
+ "expo-file-system": "~15.2.2",
"expo-font": "~11.1.1",
"expo-linking": "~4.0.1",
"expo-local-authentication": "~13.2.1",
"expo-localization": "~14.1.1",
"expo-router": "^1.0.1",
"expo-secure-store": "~12.1.1",
+ "expo-sharing": "~11.2.2",
"expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.4",
"expo-task-manager": "~11.1.1",
@@ -10813,7 +10817,6 @@
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
- "dev": true,
"engines": {
"node": ">=0.11"
},
@@ -12366,6 +12369,14 @@
"expo": "*"
}
},
+ "node_modules/expo-document-picker": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-11.2.2.tgz",
+ "integrity": "sha512-EeonRKxkK9E20LEAh93IvlwFjNkUCJKnhBEama9PmIDYWW7RyANZ8eP9C2PupThTDbivzRDNp7Ec7dIeyDAWjw==",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-file-system": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-15.2.2.tgz",
@@ -12593,6 +12604,14 @@
"expo": "*"
}
},
+ "node_modules/expo-sharing": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-11.2.2.tgz",
+ "integrity": "sha512-4Lhm1eS/CFIzX+JPuxMUTWBt9rv/WdvJvpQ9y+71bL/9w9dhvsdt9tv0SsNZATz4hk0tbrYD8ZEUsgiHiT1KkQ==",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-splash-screen": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.18.1.tgz",
@@ -33284,8 +33303,7 @@
"date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
- "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
- "dev": true
+ "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
},
"dayjs": {
"version": "1.11.7",
@@ -34639,6 +34657,12 @@
"uuid": "^3.3.2"
}
},
+ "expo-document-picker": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-11.2.2.tgz",
+ "integrity": "sha512-EeonRKxkK9E20LEAh93IvlwFjNkUCJKnhBEama9PmIDYWW7RyANZ8eP9C2PupThTDbivzRDNp7Ec7dIeyDAWjw==",
+ "requires": {}
+ },
"expo-file-system": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-15.2.2.tgz",
@@ -34800,6 +34824,12 @@
"integrity": "sha512-phD8e8mlJWaESUBCpHq0GjS3OiqZ7g+I6SpTGQpt+xSS0pEE1ZUBXnIF+WR2OvoseKiS9ODzDl85CUlut/eNKw==",
"requires": {}
},
+ "expo-sharing": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-11.2.2.tgz",
+ "integrity": "sha512-4Lhm1eS/CFIzX+JPuxMUTWBt9rv/WdvJvpQ9y+71bL/9w9dhvsdt9tv0SsNZATz4hk0tbrYD8ZEUsgiHiT1KkQ==",
+ "requires": {}
+ },
"expo-splash-screen": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.18.1.tgz",
diff --git a/package.json b/package.json
index 8b03920..8c872d3 100644
--- a/package.json
+++ b/package.json
@@ -27,21 +27,28 @@
"@react-native-masked-view/masked-view": "0.2.8",
"@react-navigation/native": "^6.1.4",
"@react-navigation/stack": "^6.3.14",
+ "@shopify/flash-list": "1.4.0",
+ "date-fns": "^2.29.3",
"dotenv": "^16.0.3",
"expo": "~48.0.6",
+ "expo-background-fetch": "~11.1.1",
"expo-barcode-scanner": "~12.3.2",
"expo-build-properties": "~0.5.1",
"expo-camera": "~13.2.1",
"expo-clipboard": "~4.1.2",
"expo-constants": "~14.2.1",
+ "expo-document-picker": "~11.2.2",
+ "expo-file-system": "~15.2.2",
"expo-font": "~11.1.1",
"expo-linking": "~4.0.1",
"expo-local-authentication": "~13.2.1",
"expo-localization": "~14.1.1",
"expo-router": "^1.0.1",
"expo-secure-store": "~12.1.1",
+ "expo-sharing": "~11.2.2",
"expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.4",
+ "expo-task-manager": "~11.1.1",
"make-plural": "^7.2.0",
"react": "18.2.0",
"react-dom": "18.2.0",
@@ -55,10 +62,7 @@
"react-native-url-polyfill": "^1.3.0",
"react-native-vector-icons": "^9.2.0",
"react-native-web": "~0.18.11",
- "totp-generator": "^0.0.14",
- "@shopify/flash-list": "1.4.0",
- "expo-task-manager": "~11.1.1",
- "expo-background-fetch": "~11.1.1"
+ "totp-generator": "^0.0.14"
},
"jest": {
"preset": "jest-expo",
diff --git a/src/components/AddSite.tsx b/src/components/AddSite.tsx
index fb0cf0d..ec9b2cf 100644
--- a/src/components/AddSite.tsx
+++ b/src/components/AddSite.tsx
@@ -11,7 +11,7 @@ const AddSite: FC = () => {
icon="plus"
style={styles.fab}
onPress={() => push('/qr')}
- label={t`Add site`}
+ label={t`Add new site`}
/>
);
};
diff --git a/src/components/SitesList.tsx b/src/components/SitesList.tsx
index 7e4ef6c..5abe968 100644
--- a/src/components/SitesList.tsx
+++ b/src/components/SitesList.tsx
@@ -10,7 +10,7 @@ import React, {
import { Trans, t } from '@lingui/macro';
import { FlashList } from '@shopify/flash-list';
-import { StyleSheet, View, ViewToken } from 'react-native';
+import { Dimensions, StyleSheet, View, ViewToken } from 'react-native';
import { TextInput } from 'react-native-paper';
import { DEFAULT_TOTP_PERIOD } from '../constants/app';
import colors from '../constants/colors';
@@ -18,6 +18,7 @@ import { useTimer30 } from '../hooks/useTimer30';
import Site from '../models/Site';
import Text from '../ui/Text';
import SiteInfo from './SiteInfo';
+import { NoSitesIcon } from '../icons/NoSites';
type SitesListProps = {
sites: Site[];
@@ -57,15 +58,21 @@ const SitesList: FC = ({ sites, deleteSite }) => {
if (!sites.length) {
return (
-
- No sites have been added yet
-
+
+
+ Everything is prepared for you to add your sites
+
+
+
);
}
return (
<>
-
+
= ({ sites, deleteSite }) => {
};
const styles = StyleSheet.create({
- inputWrapper: {
+ wrapper: {
marginBottom: 32,
},
+ wrapperNoResults: {
+ marginTop: 32,
+ },
icon: {
width: 24,
height: 24,
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx
index 743fe09..b636ab4 100644
--- a/src/components/TopBar.tsx
+++ b/src/components/TopBar.tsx
@@ -1,15 +1,48 @@
-import { FC } from 'react';
+import { t } from '@lingui/macro';
+import { FC, useState } from 'react';
import { StyleSheet, View } from 'react-native';
+import { Menu } from 'react-native-paper';
import { LogoIcon } from '../icons/LogoIcon';
+import { MenuIcon } from '../icons/MenuIcon';
+import IconButton from '../ui/IconButton';
import Text from '../ui/Text';
+import { useRouter } from 'expo-router';
const TopBar: FC = () => {
+ const { push } = useRouter();
+ const [menuVisible, setMenuVisible] = useState(false);
+
return (
-
-
- Shield Authenticator
-
+
+
+
+ Shield Authenticator
+
+
+
+
+
);
};
@@ -17,8 +50,16 @@ const TopBar: FC = () => {
const styles = StyleSheet.create({
head: {
flexDirection: 'row',
+ justifyContent: 'space-between',
alignItems: 'center',
},
+ logo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ menu: {
+ marginTop: 48,
+ },
});
export default TopBar;
diff --git a/src/icons/Export.tsx b/src/icons/Export.tsx
new file mode 100644
index 0000000..02cead4
--- /dev/null
+++ b/src/icons/Export.tsx
@@ -0,0 +1,341 @@
+import { FC } from 'react';
+import Svg, { Circle, G, Path, Text } from 'react-native-svg';
+import { ComponentWithSize } from '../types';
+
+export const ExportIcon: FC = ({
+ width = 48,
+ height = 48,
+}) => {
+ return (
+
+ );
+};
diff --git a/src/icons/Import.tsx b/src/icons/Import.tsx
new file mode 100644
index 0000000..1f9aa6b
--- /dev/null
+++ b/src/icons/Import.tsx
@@ -0,0 +1,481 @@
+import { FC } from 'react';
+import Svg, { Circle, G, Path, Text } from 'react-native-svg';
+import { ComponentWithSize } from '../types';
+
+export const ImportIcon: FC = ({
+ width = 48,
+ height = 48,
+}) => {
+ return (
+
+ );
+};
diff --git a/src/icons/MenuIcon.tsx b/src/icons/MenuIcon.tsx
new file mode 100644
index 0000000..4ec5032
--- /dev/null
+++ b/src/icons/MenuIcon.tsx
@@ -0,0 +1,26 @@
+import { FC } from 'react';
+import Svg, { Path } from 'react-native-svg';
+import colors from '../constants/colors';
+import { ComponentWithColor, ComponentWithSize } from '../types';
+
+export const MenuIcon: FC = ({
+ width = 24,
+ height = 24,
+ color = colors.medium,
+}) => {
+ return (
+
+ );
+};
diff --git a/src/icons/NoSites.tsx b/src/icons/NoSites.tsx
new file mode 100644
index 0000000..b45da7e
--- /dev/null
+++ b/src/icons/NoSites.tsx
@@ -0,0 +1,319 @@
+import { FC } from 'react';
+import Svg, { G, Path, Rect } from 'react-native-svg';
+import { ComponentWithSize } from '../types';
+
+export const NoSitesIcon: FC = ({
+ width = 48,
+ height = 48,
+}) => {
+ return (
+
+ );
+};
diff --git a/src/icons/ScanQR.tsx b/src/icons/ScanQR.tsx
index 32182de..11366a8 100644
--- a/src/icons/ScanQR.tsx
+++ b/src/icons/ScanQR.tsx
@@ -1,5 +1,5 @@
import { FC } from 'react';
-import Svg, { Circle, G, Path } from 'react-native-svg';
+import Svg, { Circle, G, Path, Text } from 'react-native-svg';
import { ComponentWithSize } from '../types';
export const ScanQRIcon: FC = ({
@@ -7,24 +7,396 @@ export const ScanQRIcon: FC = ({
height = 48,
}) => {
return (
-